Registering aeolus1d on PyPI¶
Audience: the maintainer. This is the one release step no pipeline can perform — it needs a browser and your PyPI account. It is EC-12 in release readiness, and EC-9 and EC-10 are blocked behind it.
Do this before the first upload.
aeolus1dhas never been published, so PyPI's pending publisher flow is available: you can register a trusted publisher for a project that does not exist yet, and the project is created on first publish with no long-lived API token ever existing. That window closes permanently the moment anything is uploaded with a token. Publishing once with a token and migrating later is possible but strictly worse — it means a token exists, is stored in CI, and has to be rotated and revoked.
The values to enter¶
PyPI → Your account → Publishing → Add a new pending publisher → GitLab tab.
These are no longer inferred from the repo layout. Every row below except the first is the claim GitLab actually sent, read off a real preflight run on 2026-08-08 (job 15788368642):
| Field | Value | Where it comes from |
|---|---|---|
| PyPI Project Name | aeolus1d |
pyproject.toml [project] name. Not a claim — PyPI's own name for the distribution |
| Namespace | niklas.nordin.swe |
project_path claim, before the slash |
| Project name | aeolus |
project_path claim, after the slash — the GitLab project (not the PyPI name; they differ) |
| Top-level pipeline file path | .gitlab-ci.yml |
ci_config_ref_uri claim |
| Environment name | (leave blank — both indexes) | must match the job; see below for why it is blank |
There is no namespace claim — PyPI splits project_path to get both
halves. An earlier version of this page implied otherwise.
The two names differing is the easiest thing to get wrong: the GitLab
project is aeolus (historic) while the distribution is aeolus1d
(the rename happened because aeolus was taken on PyPI). PyPI asks for
both, and they are not the same string.
Repeat the whole thing on test.pypi.org with the same values — TestPyPI is a separate site with separate accounts and its own publisher list. Do TestPyPI first: EC-9 requires the OIDC exchange to have succeeded there before the real one is attempted.
About the environment name¶
Leave it blank on both indexes. Neither publisher carries one, and
neither job declares one. GitLab omits the environment claim unless a
job declares an environment, and PyPI matches it only when the publisher
carries one — so blank-on-both is the pairing that works.
It was briefly set to pypi on TestPyPI, and the round trip is the
argument against reintroducing it. Measured while it was in place:
environment_protected = false— protected environments are a paid GitLab tier, so anyone able to push a branch could addenvironment: pypito any job. It was never access control.- Zero deployments recorded —
action: accessdoes not create one, so there was no audit trail either.
What it bought was that a job had to declare the environment as well as
request the audience: protection against an accidental job, not against a
person. What it cost was a rejected exchange
(invalid-publisher, an error that names no field) and, a day later, a
CI guard that asserted the wrong invariant because the two indexes were
configured differently. It prevented nothing and caused two failures.
If you later want an audit trail of publishes, the answer is not to
re-add it here — it is to give the release jobs action: start, which
does record a GitLab deployment per publish.
What is NOT wired yet, and must be¶
The release jobs currently use an API token, not OIDC.
.gitlab-ci.yml sets:
variables:
TWINE_USERNAME: __token__
TWINE_PASSWORD: $PYPI_API_TOKEN
There is no id_tokens: block anywhere in the file, so registering a
trusted publisher alone changes nothing — the job would still authenticate
with a token, which is exactly the thing that closes the pending-publisher
window.
Trusted publishing needs the job to request an OIDC token and exchange it:
release-pypi-test:
# No `environment:` — neither publisher is registered with one. Add it
# ONLY if you also set an Environment name on PyPI; they must agree.
id_tokens:
PYPI_ID_TOKEN:
aud: testpypi # 'pypi' for the real index
script:
- pip install build twine id
- python -m build
- python -m twine check dist/*
# exchange the GitLab OIDC token for a short-lived PyPI token
- |
OIDC=$(python -m id testpypi)
RESP=$(curl -sX POST https://test.pypi.org/_/oidc/mint-token \
-d "{\"token\":\"${OIDC}\"}")
TWINE_PASSWORD=$(python -c "import json,sys;print(json.load(sys.stdin)['token'])" <<< "$RESP")
export TWINE_PASSWORD TWINE_USERNAME=__token__
- python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*
aud differs between the two indexes (testpypi vs pypi) and a wrong
value fails at exchange, not at upload — so the error appears one step
earlier than you expect.
Once this works, PYPI_API_TOKEN / TEST_PYPI_API_TOKEN should be
deleted from the CI variables. Leaving them means the token path still
exists and can be used by accident.
Check it worked before tagging anything¶
Registering is silent — PyPI gives no signal that the values match what GitLab will actually send. The preflight job closes that gap:
Pipelines → any pipeline →
releasestage → ▶oidc-preflight-testpypi
It performs the token exchange and discards the result. It does not build, does not upload, and cannot create the project — minting a token is not publishing. So it is safe to click, and it runs on an ordinary branch pipeline with no tag and no version bump.
That is the whole reason it exists. The real release jobs only run on tag pipelines, so without the preflight every mismatch costs a fresh tag to retry — and the mismatch is nearly always one field.
On failure it prints the claims GitLab put in the token next to the field each one belongs in, so the form gets filled from measured values instead of from a reading of the repo layout:
This is the real output of the first run (job 15788368642), which failed:
claims GitLab put in the token (-> = what PyPI matches on):
aud = testpypi
ci_config_ref_uri = gitlab.com/niklas.nordin.swe/aeolus//.gitlab-ci.yml@refs/heads/feat/pypi-oidc-preflight
-> Top-level pipeline file path
iss = https://gitlab.com
project_path = niklas.nordin.swe/aeolus
-> Namespace + Project name (split on the slash)
ref = feat/pypi-oidc-preflight
ref_type = branch
environment = <ABSENT> -> leave the PyPI 'Environment name'
field BLANK, or add `environment:` to the job
At the time, the publisher was registered with Environment pypi, so
<ABSENT> was the whole fault: PyPI had an environment to match and the
token carried none. That was first fixed by adding environment: pypi to
the job — and later resolved the other way, by removing the environment
from the publisher (see About the environment name above). Both halves
blank is the configuration today.
Note what the error said — Publisher with matching claims was not found,
naming no field. Wrong namespace, wrong project name and missing
environment are byte-identical failures. The claim dump is the only thing
that separates them, which is why it prints every claim rather than the
ones someone expected to matter.
Neither the ID token nor the minted token is ever printed — both are live credentials, and job logs are retained.
Order of operations¶
The steps are coupled — the version bump is tied to the tag, and the tag is what triggers the publish — so they cannot be done independently.
- Register the pending publisher on TestPyPI (values above). EC-12
- Run
oidc-preflight-testpypiuntil it exits 0. No tag needed. Do not proceed while it is red — every later step is slower to retry. - Wire OIDC into
release-pypi-testand delete its- when: never. The preflight proves the exchange; it does not wire the release job, and until that is done the job still authenticates with$TEST_PYPI_API_TOKEN. - Bump
pyproject.tomlto0.3.0rc1. This must happen before the tag:test_version_agreement.pycomparesgit describe --tagsagainst pyproject, and0.3.0rc1 > 0.3.0.dev0under PEP 440, so tagging first turns that test red. - Tag
v0.3.0-rc1— note the hyphen. The CI rule is/^v\d+\.\d+\.\d+(?:[-+].*)?$/, whichv0.3.0rc1does not match. - Run the manual TestPyPI job. Record the job URL and the resulting file listing — that recorded evidence is EC-9.
- Register the pending publisher on real PyPI, add an
oidc-preflight-pypijob withaud: pypi, and run it. Same reasoning as step 2, and this time the door it protects is the one-way one. - Bump to
0.3.0, tagv0.3.0, in the same MR the surfaces are bumped in. EC-10 - Run the manual publish. Then delete the API-token CI variables.
Verifying it worked¶
# the exchange, not just the upload — this is what EC-9 asks for
pip download --no-deps --index-url https://test.pypi.org/simple/ aeolus1d==0.3.0rc1
# and after the real publish
pip install aeolus1d==0.3.0
python -c "import aeolus1d; print(aeolus1d.__version__)"
aeolus1d --help
A green job is not sufficient evidence. Record the job URL and the file listing PyPI shows — the readiness table asks for artefacts, not an assertion that it went fine.
If it fails¶
| Symptom | Cause |
|---|---|
invalid-publisher at mint-token |
a field mismatch — most often project name (aeolus) vs PyPI name (aeolus1d), or an environment set on one side only |
403 on upload, exchange succeeded |
the publisher matched but the project already exists under different ownership |
| Job never starts | the tag did not match /^v\d+\.\d+\.\d+(?:[-+].*)?$/ — check the hyphen in v0.3.0-rc1 |
File already exists |
TestPyPI refuses re-uploads of a version; bump the rc number |
Why this is worth the care¶
PyPI publication is one-way. Yanking a release does not remove it from
anyone's pinned installs or lockfiles, and the version number can never be
reused. That is why the release jobs carry when: never and
when: manual, and why
release-readiness.md records the release criteria
as unmet rather than rounding up — the remaining ones are this page.
It is also why the preflight exists as a separate job rather than as a
first step inside release-pypi-test. Bundling them would mean the only
way to test the exchange is to arm the thing that publishes, and this
project has been bitten repeatedly by checks that verify something
adjacent to what they appear to: a green release job that authenticated
via the fallback API token would be evidence that the token path works,
presented as evidence that trusted publishing does. Splitting the two
halves is what makes EC-9 mean what it says.