From ac9005b26d2e7ad0765ce937eed2d45a4b197e92 Mon Sep 17 00:00:00 2001 From: Eunseo Lee Date: Tue, 6 Oct 2026 16:55:29 +0900 Subject: [PATCH] fix(ci): tell a TestPyPI outage apart from a refused artifact The lane exists because a PyPI publish cannot be undone: a yanked version keeps its number forever, so Warehouse refusing an artifact is worth learning on an index you can throw away. `twine check` cannot find that for us -- it is not Warehouse's upload validator -- and the build toolchain moves on its own, since `hatchling>=1.27` has no upper bound and picks the Metadata-Version nobody in this repo chose. That finding was being reported with the same red X as "test.pypi.org was not reachable", which is nobody's bug and arrived three times in one day during a Fastly degradation. A gate that cries outage trains everyone to ignore it on the day it has something real to say. So the upload failure is now judged instead of just inherited. The unauthenticated audience endpoint answers whether the index was even there, which no repo-side configuration can influence: reachable and still refused is ours and fails loudly; unreachable on a release ref still fails, because that rehearsal is the last one before the irreversible step; unreachable on a main push warns, since the .devN it would have published gates nothing and nobody installs it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yml | 51 ++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 024d43b..071af1e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -104,10 +104,19 @@ jobs: runs-on: ubuntu-latest environment: testpypi permissions: { id-token: write } + outputs: + uploaded: ${{ steps.publish.outcome == 'success' }} steps: - uses: actions/download-artifact@v8 with: { name: dist, path: dist } - - uses: pypa/gh-action-pypi-publish@release/v1 + - id: publish + uses: pypa/gh-action-pypi-publish@release/v1 + # Tolerated, not ignored -- the next step decides what the failure meant. The two + # failures that land here are not remotely the same thing: "Warehouse refused this + # artifact" is the finding this whole lane exists to surface, and "TestPyPI was not + # there" is somebody else's outage. Reporting them as one red X is what turns this + # job into noise, and a noisy gate is one nobody reads on the day it matters. + continue-on-error: true with: repository-url: https://test.pypi.org/legacy/ # Allow re-runs on the same ref: TestPyPI rejects identical-version @@ -115,8 +124,48 @@ jobs: # cause the whole publish chain to fail. (Each main push yields a fresh # .devN, so collisions are limited to genuine re-runs / retags.) skip-existing: true + + - name: Judge the failure + if: steps.publish.outcome == 'failure' + env: + # A manual replay names the release tag in an input rather than in `github.ref`, and it + # is the same rehearsal as the tag push it replays -- so it gets the same strictness. + IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.tag != '' }} + run: | + set -euo pipefail + # The audience endpoint is the action's own first request and takes no credentials, so + # its answer is a clean read on the index itself: nothing about this repo, this + # environment or the Trusted Publisher binding is even transmitted. A 200 here + # therefore means the index was reachable and still said no -- that is our artifact. + # `|| true` only keeps `set -e` from firing: curl already prints 000 for a connection + # failure, so a second fallback echo would concatenate into 000000. + probe="$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \ + https://test.pypi.org/_/oidc/audience || true)" + if [ "$probe" = "200" ]; then + echo "::error::TestPyPI is reachable (audience probe 200) and still refused the upload." + echo "::error::This is the artifact, not an outage -- read the publish step's own error." + echo "::error::Local checks cannot see Warehouse's upload validator, which is the point of this lane." + exit 1 + fi + # A release tag is the last rehearsal before the PyPI publish that cannot be undone: + # a yanked version keeps its number forever, so a metadata defect found there costs a + # burned release instead of nothing. That rehearsal may not be waived for convenience. + if [ "$IS_RELEASE" = "true" ]; then + echo "::error::TestPyPI is unavailable (audience probe ${probe}) and this ref is a release." + echo "::error::The pre-prod rehearsal cannot be skipped here. Re-run once the index is back." + exit 1 + fi + # A main push publishes a .devN that gates nothing and that nobody installs. Failing it + # on a third-party index's uptime trains everyone to ignore a red main, which costs more + # than the few days of early warning the continuous rehearsal buys. + echo "::warning::TestPyPI is unavailable (audience probe ${probe}); the .devN rehearsal did not run." + echo "::warning::Not failed: nothing consumes the .devN, and the release tag runs this for real." testpypi-smoke: needs: [build, test-pypi] + # Nothing was uploaded, so there is nothing to install: without this the job spends five + # minutes retrying an index that never received the file and reports the outage a second + # time, as a different failure, in a job whose name points at the wrong thing. + if: needs.test-pypi.outputs.uploaded == 'true' runs-on: ubuntu-latest steps: - uses: actions/setup-python@v7