From 6eaeb03a7ca5c9d817f97795d04f3c3be380dc96 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Sat, 10 Oct 2026 14:34:05 -0700 Subject: [PATCH 1/2] fix(release): publish every package version missing from PyPI Every release-PR merge from 2026-03-09 to 03-19 failed in the publish job's tag step, which stopped the run before the build and publish steps. The 0.1.1 to 0.3.0 tags exist, but PyPI still serves gt-i18n 0.1.0 and generaltranslation 0.0.4. On a version-bump run on main, or a manual run, the publish job now builds every package version that PyPI does not have, even when tagging or the release notes failed. It asks PyPI first: a 404 builds the version, a 200 skips it, and any other status fails the run. It refuses to build a version whose package code changed since that version's tag. It publishes only when every build succeeded, and only from main. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c79146f..02d1e93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,8 @@ name: Release on: push: branches: [main] + # Run by hand to publish every package version that is not on PyPI yet. + workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -13,6 +15,7 @@ permissions: jobs: release: name: Release + if: github.event_name == 'push' runs-on: ubuntu-latest environment: name: release @@ -40,13 +43,14 @@ jobs: publish: name: Publish to PyPI + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: name: release steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 0 fetch-tags: true - uses: astral-sh/setup-uv@v6 @@ -89,16 +93,44 @@ jobs: echo "new_packages=$NEW_PACKAGES" >> "$GITHUB_OUTPUT" - - name: Build changed packages - if: steps.check.outputs.changed != '0' + # A release run (a version bump on main) or a manual run builds every package + # version that PyPI does not have, not only the newly tagged ones, and does so + # even when tagging or release notes failed. A current version that missed PyPI + # is published by the next such run. Older versions are not rebuilt. A package + # whose code changed since its version's tag stops the whole run, so nothing is + # published until that package's version is bumped. + - name: Build package versions missing from PyPI + id: build + if: ${{ !cancelled() && (steps.check.outputs.changed != '0' || github.event_name == 'workflow_dispatch') }} run: | - for pkg_name in ${{ steps.tag.outputs.new_packages }}; do - echo "Building $pkg_name..." - uv build --package "$pkg_name" --out-dir dist/ + for pkg in packages/*/; do + grep -q '\[build-system\]' "$pkg/pyproject.toml" 2>/dev/null || continue + name=$(grep '^name = ' "$pkg/pyproject.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') + version=$(grep '^version = ' "$pkg/pyproject.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') + status=$(curl -sS -o /dev/null -w '%{http_code}' --retry 3 --retry-all-errors "https://pypi.org/pypi/$name/$version/json" || true) + case "$status" in + 200) echo "$name $version is already on PyPI"; continue ;; + 404) ;; + *) echo "::error::PyPI returned $status for $name $version"; exit 1 ;; + esac + # A version must be published from the code it was tagged with. + tag="${name}@${version}" + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null && + ! git diff --quiet "$tag" HEAD -- "$pkg" ":(exclude)${pkg}README.md" ":(exclude)${pkg}CHANGELOG.md"; then + echo "::error::$name changed since $tag; bump its version before publishing" + exit 1 + fi + echo "Building $name $version" + uv build --package "$name" --out-dir dist/ done - name: Publish to PyPI - if: steps.check.outputs.changed != '0' - run: uv publish dist/* + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + if ls dist/* >/dev/null 2>&1; then + uv publish dist/* + else + echo "Every package version is already on PyPI" + fi env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} From a6abdeaaf52608bfc6933b18b810ff7886bf3984 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Fri, 16 Oct 2026 23:37:51 -0700 Subject: [PATCH 2/2] fix(release): build the file a partly uploaded version is missing uv build makes a wheel and an sdist for each package, and uv publish uploads them one at a time. If an upload stopped after the first file, PyPI answered 200 for that version, every later run skipped it, and the other file was never published. The build step now reads the version's file list from PyPI's JSON. A version with both files is skipped. A version with only one of them builds the missing one with uv build --sdist or --wheel. A version that is not on PyPI builds both, as before. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02d1e93..9daa0b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,9 +96,11 @@ jobs: # A release run (a version bump on main) or a manual run builds every package # version that PyPI does not have, not only the newly tagged ones, and does so # even when tagging or release notes failed. A current version that missed PyPI - # is published by the next such run. Older versions are not rebuilt. A package - # whose code changed since its version's tag stops the whole run, so nothing is - # published until that package's version is bumped. + # is published by the next such run. Older versions are not rebuilt. A version + # that has only its sdist or only its wheel on PyPI, because an upload stopped + # partway, gets the missing file. A package whose code changed since its + # version's tag stops the whole run, so nothing is published until that + # package's version is bumped. - name: Build package versions missing from PyPI id: build if: ${{ !cancelled() && (steps.check.outputs.changed != '0' || github.event_name == 'workflow_dispatch') }} @@ -107,12 +109,18 @@ jobs: grep -q '\[build-system\]' "$pkg/pyproject.toml" 2>/dev/null || continue name=$(grep '^name = ' "$pkg/pyproject.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') version=$(grep '^version = ' "$pkg/pyproject.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') - status=$(curl -sS -o /dev/null -w '%{http_code}' --retry 3 --retry-all-errors "https://pypi.org/pypi/$name/$version/json" || true) + status=$(curl -sS -o "$RUNNER_TEMP/pypi.json" -w '%{http_code}' --retry 3 --retry-all-errors "https://pypi.org/pypi/$name/$version/json" || true) case "$status" in - 200) echo "$name $version is already on PyPI"; continue ;; - 404) ;; + 200) on_pypi=$(jq -r '[.urls[].packagetype] | unique | join(" ")' "$RUNNER_TEMP/pypi.json") ;; + 404) on_pypi="" ;; *) echo "::error::PyPI returned $status for $name $version"; exit 1 ;; esac + case "$on_pypi" in + "bdist_wheel sdist") echo "$name $version is already on PyPI"; continue ;; + bdist_wheel) only="--sdist"; echo "$name $version is on PyPI without its sdist" ;; + sdist) only="--wheel"; echo "$name $version is on PyPI without its wheel" ;; + *) only="" ;; + esac # A version must be published from the code it was tagged with. tag="${name}@${version}" if git rev-parse -q --verify "refs/tags/$tag" >/dev/null && @@ -121,7 +129,7 @@ jobs: exit 1 fi echo "Building $name $version" - uv build --package "$name" --out-dir dist/ + uv build --package "$name" --out-dir dist/ ${only:+"$only"} done - name: Publish to PyPI