Skip to content

Commit ebdc991

Browse files
committed
Fix PyPI publish: use twine with explicit file patterns
Workflow (.github/workflows/release.yml): - Replace pypa/gh-action-pypi-publish with twine upload - Add PyPI version check before upload (skip if exists) - Add final validation step with twine check on all packages - Use explicit patterns (dist/*.whl dist/*.tar.gz) to avoid uploading metadata files (build-info.txt, CHECKSUMS.sha256) - Remove keep-metadata from check-release-fileset for PyPI - Match autobahn-python release workflow pattern Justfile (publish-pypi, publish-rtd, publish): - Sync publish-pypi recipe with autobahn-python pattern - Download to temp dir, use explicit patterns for twine upload - Add tag format validation (vX.Y.Z) - Add wheel/sdist count verification - Sync publish-rtd recipe with autobahn-python (RTD API trigger) - Add publish meta-recipe summary output This fixes the "InvalidDistribution: Unknown distribution format: 'build-info.txt'" error during PyPI publish. Note: This work was completed with AI assistance (Claude Code).
1 parent ea8a00e commit ebdc991

File tree

2 files changed

+283
-28
lines changed

2 files changed

+283
-28
lines changed

.github/workflows/release.yml

Lines changed: 146 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,7 @@ jobs:
804804
with:
805805
distdir: dist
806806
mode: strict
807+
# keep-metadata: false (default - removes CHECKSUMS, build-info.txt etc for PyPI)
807808
targets: |
808809
cpy311-linux-x86_64-manylinux_2_28
809810
cpy312-linux-x86_64-manylinux_2_28
@@ -826,13 +827,148 @@ jobs:
826827
cpy314-win-amd64
827828
source
828829
829-
- name: Publish to PyPI
830-
uses: pypa/gh-action-pypi-publish@release/v1
831-
with:
832-
# Use OIDC trusted publishing (no password needed)
833-
# Automatically generates and uploads attestations
834-
packages-dir: dist/
835-
verify-metadata: true
836-
skip-existing: false
837-
print-hash: true
838-
attestations: true
830+
- name: Check if version already exists on PyPI
831+
id: pypi_check
832+
run: |
833+
# Extract version from tag name (v25.12.2 -> 25.12.2)
834+
VERSION="${{ needs.check-workflows.outputs.tag_name }}"
835+
VERSION="${VERSION#v}"
836+
echo "Checking if zlmdb version ${VERSION} exists on PyPI..."
837+
838+
# Query PyPI JSON API
839+
HTTP_CODE=$(curl -s -o /tmp/pypi_response.json -w "%{http_code}" "https://pypi.org/pypi/zlmdb/${VERSION}/json")
840+
841+
if [ "${HTTP_CODE}" = "200" ]; then
842+
echo "⚠️ WARNING: Version ${VERSION} already exists on PyPI!"
843+
echo "⚠️ PyPI does not allow re-uploading the same version."
844+
echo "⚠️ Skipping PyPI upload to avoid error."
845+
echo "exists=true" >> $GITHUB_OUTPUT
846+
elif [ "${HTTP_CODE}" = "404" ]; then
847+
echo "✅ Version ${VERSION} does not exist on PyPI yet - proceeding with upload"
848+
echo "exists=false" >> $GITHUB_OUTPUT
849+
else
850+
echo "⚠️ Unexpected HTTP code ${HTTP_CODE} from PyPI API"
851+
echo "⚠️ Response:"
852+
cat /tmp/pypi_response.json || echo "(no response)"
853+
echo "⚠️ Proceeding with upload anyway (will fail if version exists)"
854+
echo "exists=false" >> $GITHUB_OUTPUT
855+
fi
856+
857+
rm -f /tmp/pypi_response.json
858+
859+
- name: Final validation before PyPI upload
860+
if: steps.pypi_check.outputs.exists == 'false'
861+
run: |
862+
set -o pipefail
863+
echo "======================================================================"
864+
echo "==> FINAL PYPI VALIDATION: All Packages"
865+
echo "======================================================================"
866+
echo ""
867+
echo "Last chance to catch corrupted packages before PyPI upload."
868+
echo ""
869+
# Install both packaging and twine from master for PEP 639 (Core Metadata 2.4) support
870+
# Use --break-system-packages for consistency (safe in CI)
871+
python3 -m pip install --break-system-packages git+https://github.com/pypa/packaging.git
872+
python3 -m pip install --break-system-packages git+https://github.com/pypa/twine.git
873+
echo ""
874+
875+
echo "==> Validation environment:"
876+
echo "Python: $(python3 --version)"
877+
echo "setuptools: $(python3 -m pip show setuptools | grep '^Version:' || echo 'not installed')"
878+
echo "packaging: $(python3 -m pip show packaging | grep '^Version:' || echo 'not installed')"
879+
echo "twine: $(twine --version)"
880+
echo ""
881+
882+
HAS_ERRORS=0
883+
884+
for pkg in dist/*.whl dist/*.tar.gz; do
885+
if [ ! -f "$pkg" ]; then
886+
continue
887+
fi
888+
889+
PKG_NAME=$(basename "$pkg")
890+
echo "==> Validating: $PKG_NAME"
891+
892+
# For wheels: full integrity check
893+
if [[ "$pkg" == *.whl ]]; then
894+
if ! unzip -t "$pkg" > /dev/null 2>&1; then
895+
echo " ❌ ZIP test FAIL - CORRUPTED WHEEL!"
896+
HAS_ERRORS=1
897+
elif ! python3 -m zipfile -t "$pkg" > /dev/null 2>&1; then
898+
echo " ❌ Python zipfile test FAIL - CORRUPTED WHEEL!"
899+
HAS_ERRORS=1
900+
else
901+
# Run twine check and capture output
902+
twine check "$pkg" 2>&1 | tee /tmp/twine_pypi_output.txt
903+
TWINE_EXIT=${PIPESTATUS[0]}
904+
905+
# Fail on nonzero exit or any error-like output
906+
if [ "$TWINE_EXIT" -eq 0 ] && ! grep -Eqi "ERROR|FAILED|InvalidDistribution" /tmp/twine_pypi_output.txt; then
907+
echo " ✅ All checks PASS"
908+
else
909+
echo " ❌ Twine check FAIL"
910+
cat /tmp/twine_pypi_output.txt
911+
HAS_ERRORS=1
912+
fi
913+
rm -f /tmp/twine_pypi_output.txt
914+
fi
915+
# For source dists: gzip + tar integrity
916+
elif [[ "$pkg" == *.tar.gz ]]; then
917+
if ! gzip -t "$pkg" 2>/dev/null; then
918+
echo " ❌ Gzip test FAIL - CORRUPTED TARBALL!"
919+
HAS_ERRORS=1
920+
elif ! tar -tzf "$pkg" > /dev/null 2>&1; then
921+
echo " ❌ Tar test FAIL - CORRUPTED TARBALL!"
922+
HAS_ERRORS=1
923+
else
924+
# Run twine check and capture output
925+
twine check "$pkg" 2>&1 | tee /tmp/twine_pypi_output.txt
926+
TWINE_EXIT=${PIPESTATUS[0]}
927+
928+
# Fail on nonzero exit or any error-like output
929+
if [ "$TWINE_EXIT" -eq 0 ] && ! grep -Eqi "ERROR|FAILED|InvalidDistribution" /tmp/twine_pypi_output.txt; then
930+
echo " ✅ All checks PASS"
931+
else
932+
echo " ❌ Twine check FAIL"
933+
cat /tmp/twine_pypi_output.txt
934+
HAS_ERRORS=1
935+
fi
936+
rm -f /tmp/twine_pypi_output.txt
937+
fi
938+
fi
939+
echo ""
940+
done
941+
942+
if [ $HAS_ERRORS -eq 1 ]; then
943+
echo "======================================================================"
944+
echo "❌ PYPI VALIDATION FAILED - UPLOAD BLOCKED"
945+
echo "======================================================================"
946+
echo ""
947+
echo "Corrupted packages detected. PyPI upload BLOCKED."
948+
echo ""
949+
exit 1
950+
else
951+
echo "======================================================================"
952+
echo "✅ ALL PACKAGES VALIDATED - Safe to upload to PyPI"
953+
echo "======================================================================"
954+
fi
955+
956+
- name: Publish to PyPI using bleeding-edge twine
957+
if: steps.pypi_check.outputs.exists == 'false'
958+
env:
959+
TWINE_USERNAME: __token__
960+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
961+
run: |
962+
echo "==> Publishing to PyPI using twine from master..."
963+
# Install bleeding-edge packaging and twine for PEP 639 support
964+
# Use --break-system-packages for consistency (safe in CI)
965+
python3 -m pip install --break-system-packages git+https://github.com/pypa/packaging.git
966+
python3 -m pip install --break-system-packages git+https://github.com/pypa/twine.git
967+
968+
echo "Upload environment:"
969+
echo "twine: $(twine --version)"
970+
echo "packaging: $(python3 -m pip show packaging | grep '^Version:')"
971+
echo ""
972+
973+
# Upload to PyPI - explicit patterns to avoid uploading metadata files
974+
twine upload dist/*.whl dist/*.tar.gz --verbose

justfile

Lines changed: 137 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,8 +1388,22 @@ dist venv="": clean-build (build venv) (build-sourcedist venv)
13881388
echo "==> Contents of wheel:"
13891389
unzip -l dist/zlmdb-*-py*.whl || echo "Wheel not found"
13901390

1391-
# Publish package to PyPI (requires twine setup) - meta-recipe
1391+
# Publish package to PyPI and Read the Docs (meta-recipe)
13921392
publish venv="" tag="": (publish-pypi venv tag) (publish-rtd tag)
1393+
#!/usr/bin/env bash
1394+
set -e
1395+
TAG="{{ tag }}"
1396+
if [ -z "${TAG}" ]; then
1397+
TAG=$(git describe --tags --abbrev=0)
1398+
fi
1399+
echo ""
1400+
echo "════════════════════════════════════════════════════════════"
1401+
echo "✅ Successfully published version ${TAG}"
1402+
echo "════════════════════════════════════════════════════════════"
1403+
echo ""
1404+
echo "📦 PyPI: https://pypi.org/project/zlmdb/${TAG#v}/"
1405+
echo "📚 RTD: https://zlmdb.readthedocs.io/en/${TAG}/"
1406+
echo ""
13931407

13941408
# Download GitHub release artifacts (usage: `just download-github-release` for nightly, or `just download-github-release stable`)
13951409
download-github-release release_type="nightly":
@@ -1409,45 +1423,150 @@ download-github-release release_type="nightly":
14091423
ls -la dist/
14101424

14111425
# Download release artifacts from GitHub and publish to PyPI
1412-
publish-pypi venv="" tag="": (install-tools venv)
1426+
publish-pypi venv="" tag="":
14131427
#!/usr/bin/env bash
14141428
set -e
14151429
VENV_NAME="{{ venv }}"
14161430
if [ -z "${VENV_NAME}" ]; then
1431+
echo "==> No venv name specified. Auto-detecting from system Python..."
14171432
VENV_NAME=$(just --quiet _get-system-venv-name)
1433+
echo "==> Defaulting to venv: '${VENV_NAME}'"
14181434
fi
1419-
VENV_PYTHON=$(just --quiet _get-venv-python "${VENV_NAME}")
1435+
VENV_PATH="{{ VENV_DIR }}/${VENV_NAME}"
14201436

1437+
# Determine which tag to use
14211438
TAG="{{ tag }}"
14221439
if [ -z "${TAG}" ]; then
1423-
echo "==> No tag specified, using local build..."
1424-
just dist ${VENV_NAME}
1425-
else
1426-
echo "==> Downloading release artifacts for tag ${TAG}..."
1427-
mkdir -p dist/
1428-
gh release download --repo crossbario/zlmdb --pattern "*.whl" --pattern "*.tar.gz" --dir dist/ "${TAG}"
1440+
echo "==> No tag specified. Using latest git tag..."
1441+
TAG=$(git describe --tags --abbrev=0)
1442+
echo "==> Using tag: ${TAG}"
1443+
fi
1444+
1445+
# Verify tag looks like a version tag
1446+
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
1447+
echo "❌ Error: Tag '${TAG}' doesn't look like a version tag (expected format: vX.Y.Z)"
1448+
exit 1
1449+
fi
1450+
1451+
# Create temp directory for downloads
1452+
TEMP_DIR=$(mktemp -d)
1453+
echo "==> Downloading release artifacts from GitHub release ${TAG}..."
1454+
echo " Temp directory: ${TEMP_DIR}"
1455+
1456+
# Download all release assets
1457+
gh release download "${TAG}" --repo crossbario/zlmdb --dir "${TEMP_DIR}"
1458+
1459+
echo ""
1460+
echo "==> Downloaded files:"
1461+
ls -lh "${TEMP_DIR}"
1462+
echo ""
1463+
1464+
# Count wheels and source distributions
1465+
WHEEL_COUNT=$(find "${TEMP_DIR}" -name "*.whl" | wc -l)
1466+
SDIST_COUNT=$(find "${TEMP_DIR}" -name "*.tar.gz" | wc -l)
1467+
1468+
echo "Found ${WHEEL_COUNT} wheel(s) and ${SDIST_COUNT} source distribution(s)"
1469+
1470+
if [ "${WHEEL_COUNT}" -eq 0 ] || [ "${SDIST_COUNT}" -eq 0 ]; then
1471+
echo "❌ Error: Expected at least 1 wheel and 1 source distribution"
1472+
echo " Wheels found: ${WHEEL_COUNT}"
1473+
echo " Source dist found: ${SDIST_COUNT}"
1474+
rm -rf "${TEMP_DIR}"
1475+
exit 1
1476+
fi
1477+
1478+
# Ensure twine is installed
1479+
if [ ! -f "${VENV_PATH}/bin/twine" ]; then
1480+
echo "==> Installing twine in ${VENV_NAME}..."
1481+
"${VENV_PATH}/bin/pip" install twine
14291482
fi
14301483

1431-
echo "==> Verifying artifacts..."
1432-
${VENV_PYTHON} -m twine check dist/*
1484+
echo "==> Publishing to PyPI using twine..."
1485+
# Use explicit patterns to avoid uploading metadata files (build-info.txt, CHECKSUMS.sha256, etc.)
1486+
"${VENV_PATH}/bin/twine" upload "${TEMP_DIR}"/*.whl "${TEMP_DIR}"/*.tar.gz
14331487

1434-
echo "==> Publishing to PyPI..."
1435-
${VENV_PYTHON} -m twine upload dist/*
1488+
# Cleanup
1489+
rm -rf "${TEMP_DIR}"
1490+
echo "✅ Successfully published ${TAG} to PyPI"
14361491

14371492
# Trigger Read the Docs build for a specific tag
14381493
publish-rtd tag="":
14391494
#!/usr/bin/env bash
14401495
set -e
1496+
1497+
# Determine which tag to use
14411498
TAG="{{ tag }}"
14421499
if [ -z "${TAG}" ]; then
1443-
echo "==> No tag specified. RTD will build from webhook on push."
1444-
echo " To manually trigger: https://readthedocs.org/projects/zlmdb/builds/"
1500+
echo "==> No tag specified. Using latest git tag..."
1501+
TAG=$(git describe --tags --abbrev=0)
1502+
echo "==> Using tag: ${TAG}"
1503+
fi
1504+
1505+
# Verify tag looks like a version tag
1506+
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
1507+
echo "❌ Error: Tag '${TAG}' doesn't look like a version tag (expected format: vX.Y.Z)"
1508+
exit 1
1509+
fi
1510+
1511+
# Check if RTD_TOKEN is set
1512+
if [ -z "${RTD_TOKEN}" ]; then
1513+
echo "❌ Error: RTD_TOKEN environment variable is not set"
1514+
echo ""
1515+
echo "To trigger RTD builds, you need to:"
1516+
echo "1. Get an API token from https://readthedocs.org/accounts/tokens/"
1517+
echo "2. Export it: export RTD_TOKEN=your_token_here"
1518+
echo ""
1519+
exit 1
1520+
fi
1521+
1522+
echo "==> Triggering Read the Docs build for ${TAG}..."
1523+
echo ""
1524+
1525+
# Trigger build via RTD API
1526+
# See: https://docs.readthedocs.io/en/stable/api/v3.html#post--api-v3-projects-(string-project_slug)-versions-(string-version_slug)-builds-
1527+
RTD_PROJECT="zlmdb"
1528+
RTD_API_URL="https://readthedocs.org/api/v3/projects/${RTD_PROJECT}/versions/${TAG}/builds/"
1529+
1530+
echo "==> Calling RTD API..."
1531+
echo " Project: ${RTD_PROJECT}"
1532+
echo " Version: ${TAG}"
1533+
echo " URL: ${RTD_API_URL}"
1534+
echo ""
1535+
1536+
# Trigger the build
1537+
HTTP_CODE=$(curl -X POST \
1538+
-H "Authorization: Token ${RTD_TOKEN}" \
1539+
-w "%{http_code}" \
1540+
-s -o /tmp/rtd_response.json \
1541+
"${RTD_API_URL}")
1542+
1543+
echo "==> API Response (HTTP ${HTTP_CODE}):"
1544+
cat /tmp/rtd_response.json | python3 -m json.tool 2>/dev/null || cat /tmp/rtd_response.json
1545+
echo ""
1546+
1547+
if [ "${HTTP_CODE}" = "202" ] || [ "${HTTP_CODE}" = "201" ]; then
1548+
echo "✅ Read the Docs build triggered successfully!"
1549+
echo ""
1550+
echo "Check build status at:"
1551+
echo " https://readthedocs.org/projects/${RTD_PROJECT}/builds/"
1552+
echo ""
1553+
echo "Documentation will be available at:"
1554+
echo " https://${RTD_PROJECT}.readthedocs.io/en/${TAG}/"
1555+
echo " https://${RTD_PROJECT}.readthedocs.io/en/stable/ (if marked as stable)"
1556+
echo ""
14451557
else
1446-
echo "==> RTD build triggered by GitHub webhook on tag push."
1447-
echo " Monitor build at: https://readthedocs.org/projects/zlmdb/builds/"
1448-
echo " Documentation will be available at: https://zlmdb.readthedocs.io/en/${TAG}/"
1558+
echo "❌ Error: Failed to trigger RTD build (HTTP ${HTTP_CODE})"
1559+
echo ""
1560+
echo "Common issues:"
1561+
echo "- Invalid RTD_TOKEN"
1562+
echo "- Version/tag doesn't exist in RTD project"
1563+
echo "- Network/API connectivity problems"
1564+
echo ""
1565+
exit 1
14491566
fi
14501567

1568+
rm -f /tmp/rtd_response.json
1569+
14511570
# -----------------------------------------------------------------------------
14521571
# -- Utilities
14531572
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)