Repository navigation
fix(ci): tell a TestPyPI outage apart from a refused artifact - #130
Merged
Merged
Conversation
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) <noreply@anthropic.com>
kdh-podonos
approved these changes
Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Publish (TestPyPI)failed three times in one day, every time with the same error:None of it was ours. Measured while diagnosing it:
GET https://test.pypi.org/_/oidc/audienceand it carries no credentials — reproducing it from a laptop with zero configuration returns the same 503. Nothing about this repo, thetestpypienvironment or the Trusted Publisher binding is even transmitted, so none of them can influence the result.200responses come backserver: gunicorn/application/json;503responses come backserver: Varnish/text/htmlwithretry-after: 0. The origin is healthy — Fastly is synthesizing the 503 when its edge→origin fetch fails.pypi.orgis unaffected because it has an origin shield (x-served-byshows an IAD chain,x-cache: MISS, HIT, HIT).test.pypi.orghas none: a single POP,MISSevery time, so every edge node fetches origin directly with no cached layer to absorb a failure.5% → 44% → 70% → 94%over an hour with nothing moving in this repo — and at the end,audienceand/legacy/were never healthy in the same 15-second window across 32 checks.sha256:a68d0551…), andpublish.ymlhad not changed since that success.The real cost is not the lost
.devN. It is that "Warehouse refused this artifact" — the one finding this lane exists to surface — was being reported with the same red X as somebody else's CDN wobble. A gate that cries outage is one nobody reads on the day it has something real to say.And it does have something real to say.
twine checkis not Warehouse's upload validator; it checks that the README renders. Warehouse separately enforces acceptedMetadata-Versionvalues, PEP 625 filename normalization, size limits and wheel tag validity. This repo is actually exposed there:hatchling>=1.27has no upper bound, sopython -m buildpicks up whatever hatchling exists that day and emits aMetadata-Version(currently2.5) that nobody here chose. If that moves ahead of what PyPI accepts, every local check passes and only the upload says no — on a day when no one committed anything.That matters because the prod publish cannot be undone. Per RUNBOOK,
twine deleteis forbidden andtwine yankleaves the version number permanently occupied: a metadata defect found on PyPI burns a release number, while the same defect found on TestPyPI costs nothing.Solution
Judge the upload failure instead of inheriting it. The unauthenticated audience endpoint answers the one question that separates the two cases — was the index even there?
testpypi-smokeis now gated onneeds.test-pypi.outputs.uploaded; without that it spends five minutes retrying an index that never received the file and reports the same outage a second time under a job name pointing at the wrong thing.notify-failureis untouched: the tolerated path ends the job green so Slack stays quiet, and both failing paths still reach it.A release ref is strict whether it arrives as a tag push or as a
workflow_dispatchreplay — the input names the tag rather thangithub.ref, and it is the same rehearsal.Deliberately not included: retrying the publish. At the rates measured here three attempts still fail ~34% of the time, so retry does not solve this and would only blur what the job is reporting.
Verification
publish.ymlparses;test-pypiexposesuploaded, the publish step carriescontinue-on-error+id=publish, andJudge the failurefollows it.exit 1naming the artifact; outage + release →exit 1; outage + main push →exit 0with a warning;curlconnection failure (probe=000) →exit 0with a warning.|| echo 000producedprobe=000000, because curl already prints000itself on a connection failure. It is|| truenow, verified against a dead host.set -euo pipefailreturns the current503, so the tolerated path is the one this change was written for.🤖 Generated with Claude Code