We ship one small API client in several languages. The code is nearly identical each time: five methods, typed models, no runtime dependencies. Publishing it was different in every ecosystem, and not in the ways the documentation prepares you for. PyPI, npm, Packagist and the MCP Registry each have a different idea of what it means to prove a release came from you, and two of them have a trap that will cost you an afternoon.
This is what actually happened, with version numbers and error strings, so you can skip the parts that cost us time.
Why PyPI is the easiest
Because PyPI lets you describe the publisher before the package exists. You register a pending publisher against a repository, a workflow filename and an environment, and the first release from that workflow creates the project. No token is ever generated, stored or rotated.
name: publish
on:
release:
types: [published]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # Trusted Publishing (OIDC), no token stored
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- run: python -m pip install --upgrade build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
Create the GitHub environment named in environment: before you cut the release, or the job fails on a missing environment rather than on anything to do with publishing.
Why npm cannot do the same thing
npm supports OIDC, but it has no equivalent of a pending publisher, so OIDC cannot perform a package's first publish. Version 0.1.0 has to go out with a manual npm publish, and only then can trust attach to something that exists. This is a CLI limitation, tracked as npm/cli issue 8544, and the published documentation does not mention it.
Configuring trust afterwards has its own edge. On npm 11.11.0 the command returns a bare 400 Bad Request with no error body:
npm trust github --file publish.yml --repository owner/repo --environment npm
# 400 Bad Request
A registry rule introduced on 20 May 2026 requires the trust configuration to name an allowed action, and that npm version predates the flag that expresses it. Newer versions say so in plain words. If you cannot upgrade globally, run the newer CLI directly:
npx -y npm@11.19.0 trust github \
--file publish.yml --repository owner/repo \
--environment npm --allow-publish
One more practical detail: the npm CLI redacts two-factor approval URLs when it does not think it is attached to a terminal, so in CI-style output you get a censored link you cannot open. Allocate a pty and the real URL appears:
printf '\n' | script -q /dev/null npm publish
Those links expire within minutes, so only run commands that trigger them when you are actually at the keyboard.
Why Packagist needs no workflow at all
Because it does not publish anything. You submit the repository URL once, and from then on every tag matching v* becomes a version. There is no OIDC, no token, no publish.yml.
The consequence is a rule that catches people coming from npm: your composer.json must not contain a version field. The tag is the single source of truth, and a hardcoded version will fight it.
{
"name": "ibanchecker/client",
"require": { "php": "^8.0", "ext-curl": "*", "ext-json": "*" }
}
We expected to have to install a webhook manually, because the account was registered by email rather than by connecting GitHub. Packagist installed it itself, and the package page reads "auto-updated" without anyone touching it.
What is different about the MCP Registry
The MCP Registry is young enough that its manifest schema still moves. A server.json that validated last month can be rejected this month for a renamed field. Validate before you publish rather than after:
mcp-publisher validate
The payoff is that registry listings propagate: one MCP Registry entry is picked up by the directories that index it, so a small server ends up discoverable in several places from a single publish.
The trap that wastes the most time: checking whether a name is free
Before any of this you need a package name, and the obvious way to check PyPI is to open the project page. Do not. That URL returns 200 OK for names that do not exist, because an unauthenticated request can be served a bot challenge with a success status. You will conclude the name is taken and pick a worse one.
Compare a name that exists with one nobody owns:
# a name that exists
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/project/ibanchecker/ # 200
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/ibanchecker/json # 200
# a name that does not exist
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/project/zzz-not-taken-xyz/ # 200 <- lies
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/zzz-not-taken-xyz/json # 404 <- truth
Use the JSON endpoint, or /simple/<name>/. Both answer honestly.
How the four compare
| Registry | First publish | Later releases | Secret stored |
|---|---|---|---|
| PyPI | OIDC, via a pending publisher | OIDC | None |
| npm | Manual, OIDC cannot | OIDC once trust is set | None after setup |
| Packagist | Submit the repo URL | Any v* tag |
None |
| MCP Registry | mcp-publisher |
Same | None |
The through-line is that none of the four needs a long-lived token any more, and every one of them gets there by a different route. If you are shipping to more than one, budget the time for npm and leave PyPI until last, because it is the one that will just work.
The worked example
If you want to read a small, boring, dependency-free implementation of the same client four times: ibanchecker on PyPI, ibanchecker/client on Packagist, @ibanchecker/client on npm, and @ibanchecker/mcp for the MCP server. They call the free IBAN validation API documented at ibanchecker.cash/api-docs.
I also wrote up the npm 400 in more detail, if that is the part you hit: npm trust github fails with 400 Bad Request.``
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.