Skip to content

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. aeolus1d has 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 add environment: pypi to any job. It was never access control.
  • Zero deployments recorded — action: access does 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 → release stage → ▶ 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.

  1. Register the pending publisher on TestPyPI (values above). EC-12
  2. Run oidc-preflight-testpypi until it exits 0. No tag needed. Do not proceed while it is red — every later step is slower to retry.
  3. Wire OIDC into release-pypi-test and 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.
  4. Bump pyproject.toml to 0.3.0rc1. This must happen before the tag: test_version_agreement.py compares git describe --tags against pyproject, and 0.3.0rc1 > 0.3.0.dev0 under PEP 440, so tagging first turns that test red.
  5. Tag v0.3.0-rc1 — note the hyphen. The CI rule is /^v\d+\.\d+\.\d+(?:[-+].*)?$/, which v0.3.0rc1 does not match.
  6. Run the manual TestPyPI job. Record the job URL and the resulting file listing — that recorded evidence is EC-9.
  7. Register the pending publisher on real PyPI, add an oidc-preflight-pypi job with aud: pypi, and run it. Same reasoning as step 2, and this time the door it protects is the one-way one.
  8. Bump to 0.3.0, tag v0.3.0, in the same MR the surfaces are bumped in. EC-10
  9. 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.