Package registries
- Publish an npm package
- Install npm packages from your registry
- Search the npm registry
- Publish a crate
- Depend on a crate from your registry
- Check what Cargo sees for a crate
- Yank a crate version
- Restore a yanked crate version
- Search the Cargo registry
- Publish a Python distribution
- Install Python packages from your registry
- List every Python project in a registry
- Download one distribution file
- See what a group has published
- Inspect a package and its versions
- Delete one version
- Delete a package and every version of it
Overview
Publish private npm, Cargo and PyPI packages to a group and install them with the tools you already have. npm publish, cargo publish and twine upload all work against a Gitmoap registry, and npm install, cargo build and pip install read from it — authenticated with the same access token as the rest of the platform, authorized by the same group membership, and charged against the same storage budget as the group's repositories.
A registry belongs to a group, not to a repository, which is what makes it usable across an organisation: the people who can see a group's code are the people who can install its packages, and a monorepo publishing twelve packages needs no extra plumbing. There is nothing to create — a registry starts existing the first time somebody publishes to it.
Quick start
Publish an npm package
GITMOAP_TOKEN.Point your package's scope at this group's registry in the project's
.npmrc, with your own scope in place of@your-scope:@your-scope:registry=https://registry.example.com/packages/0192f4c1-7a3e-7c2d-9b1e-5f6a7b8c9d0e/npm/ //registry.example.com/packages/0192f4c1-7a3e-7c2d-9b1e-5f6a7b8c9d0e/npm/:_authToken=${GITMOAP_TOKEN}
Publish from the package's directory. It appears under Packages with its newest version.
npm publish
Publish a crate
GITMOAP_TOKEN.Declare the registry in
.cargo/config.toml— note thesparse+prefix:[registries.gitmoap] index = "sparse+https://registry.example.com/packages/0192f4c1-7a3e-7c2d-9b1e-5f6a7b8c9d0e/cargo/"Publish with the token in the environment. The crate appears under Packages.
CARGO_REGISTRIES_GITMOAP_TOKEN="$GITMOAP_TOKEN" cargo publish --registry gitmoap
Publish a Python package
GITMOAP_TOKEN, and build and twine installed.Build the distributions from the project's directory:
python -m build
Upload them with the token as the password. The project appears under Packages.
TWINE_USERNAME=__token__ TWINE_PASSWORD="$GITMOAP_TOKEN" twine upload --repository-url https://registry.example.com/packages/0192f4c1-7a3e-7c2d-9b1e-5f6a7b8c9d0e/pypi/ dist/*
Before you start
- A group, and its id.Every registry URL is addressed by the group's id rather than its path, so renaming or moving the group never breaks a build. Take the id from the group's page in the web app, or from
gitmoap groups get-by-path --full-path acme/platform. - Access on the group.Reporter to install and to read package metadata, Developer to publish and to yank or restore a Cargo version, Maintainer to delete a version or a whole package. Access inherits down a group hierarchy, so Developer on a parent group is Developer on its children's registries.
- A token with the right scope.Package managers authenticate with a Gitmoap access token — a personal one (
gmp_…) or a service-account one (gms_…) for CI. The scopes arepackage:readto install,package:writeto publish and yank,package:deleteto delete. Scopes are chosen when the token is minted and cannot be added afterwards, so a CI job that only restores dependencies should hold a token carryingpackage:readand nothing else. - Visibility decides who can install without signing in.A public group's packages are readable by anyone, including anonymous clients. An internal group's are readable by anyone signed in. A private group's are readable only by its members, and a signed-in non-member is answered
404rather than403so the group's existence stays private. A client carrying no token at all is answered401by anything that is not public. - The client tools themselves.npm reading a token from
.npmrc, Cargo with the sparse-registry protocol, andtwinepluspipfor Python. Nothing needs a plugin.
How it works
One registry per group and per format
A group has up to three registries — one for each ecosystem it speaks — and they are independent of each other. These are the URLs you point a tool at, with the group's id substituted in:
| Format | What you point the tool at |
|---|---|
| npm | https://api.gitmoap.com/packages/{group_id}/npm/ |
| Cargo | sparse+https://api.gitmoap.com/packages/{group_id}/cargo/ |
| PyPI — installing | https://api.gitmoap.com/packages/{group_id}/pypi/simple/ |
| PyPI — uploading | https://api.gitmoap.com/packages/{group_id}/pypi/ |
Inside a registry, everything hangs off the package name in a fixed shape. The management endpoints address the package and the version; the file is the level a download names:
group└── registry npm | cargo | pypi └── package lodash └── version 4.17.21 └── file lodash-4.17.21.tgzA published version is immutable
A (package, version, filename) coordinate can be written once. Publishing the identical bytes to a coordinate that already holds them succeeds and changes nothing — which is what makes a retried CI job safe. Publishing different bytes to a coordinate that is taken is refused with 409 CONFLICT, and nothing about the stored version moves.
Where the tool declares a digest, it is checked against the bytes received before anything is stored: npm's SHA-512 integrity string and its SHA-1 shasum, and the sha256_digest twine sends with each file. A digest that does not match is 400 BAD_REQUEST, and the artifact is discarded. cargo publish declares nothing, so the SHA-256 recorded in the index is computed here from the bytes that arrived — and that computed checksum is what Cargo verifies every later download against.
Yank hides a version, delete destroys it
The two are not variations on the same idea.
| Yank (Cargo only) | Delete | |
|---|---|---|
| What happens | a flag flips on the version | metadata and bytes are removed |
| The file | stays exactly where it is | gone |
| Storage | unchanged | released back to the namespace budget |
| Existing lockfiles | keep resolving | fail to resolve |
| New dependency resolution | skips the version | cannot see the version |
| Reversible | yes, with unyank | no |
| Access needed | Developer | Maintainer |
Yank is the right answer for a bad release: it stops the version being picked up by anything new without breaking anyone who already depends on it. Delete is for bytes that must genuinely stop existing.
Names are normalised per ecosystem
Each format has its own idea of what two names being "the same" means, and Gitmoap stores each name the way that format expects. This is the name the management endpoints take, so it is worth knowing:
| Format | Stored as | My_Package.Core becomes |
|---|---|---|
| npm | exactly as published, scope kept | My_Package.Core |
| Cargo | lowercased | my_package.core |
| PyPI | lowercased, with runs of ., - and _ collapsed to one - | my-package-core |
A name may be 1–214 characters of A–Z a–z 0–9 . _ - @ /, may not contain .., and may not start with - or / or end with /. A version is 1–128 characters of A–Z a–z 0–9 . _ + ! -, which covers both semver and PEP 440. A distribution filename is 1–255 characters of A–Z a–z 0–9 . _ + -, so it can carry no path separator and no ... Neither a version nor a filename may start with -.
Downloads are redirects
Fetching a tarball, a .crate or a wheel answers 302 with a URL to the bytes, so the file never travels through the API itself. Every package manager follows that automatically; a hand-written HTTP client has to be told to (curl -L). The URL it redirects to is good for five minutes, so it is not something to cache in a config file or paste into a ticket.
Your token rides whichever header the tool sends
Each ecosystem's client authenticates differently, and all three carriers are accepted. You never convert a token into anything — the same gmp_/gms_ string goes in all three places.
| Tool | Header it sends | Where you put the token |
|---|---|---|
| npm | Authorization: Bearer … | the _authToken line in .npmrc |
| Cargo | Authorization: <token> | Cargo's credentials file, or CARGO_REGISTRIES_<NAME>_TOKEN |
| pip and twine | Authorization: Basic … | the password half; any username works, and __token__ is the convention |
A registry serves only what you publish to it
It holds your packages and nothing else — it does not mirror or proxy npmjs.com, crates.io or PyPI. Keep the public registry configured alongside it and route only your own names to Gitmoap: an npm scope mapping, a named Cargo registry, an extra index for pip. Replacing the public default outright means every third-party dependency stops resolving.
Every write is on the record
Publishing, yanking, unyanking and deleting are recorded in the owning group's audit log, with the person or service account that did it. Reads are not — installing a package leaves no audit entry, so the log answers "who shipped this version" and "who removed it", never "who consumed it".
How to
Publish an npm package
Map your scope to the registry and put the token beside it. In the project's .npmrc:
@acme:registry=https://api.gitmoap.com/packages/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/npm///api.gitmoap.com/packages/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/npm/:_authToken=gmp_your_token_hereThen npm publish from the package directory. The dist-tag is set at publish time — npm publish --tag beta files the version under beta instead of latest. The tag map a package advertises is exactly the one its most recent publish sent, so a publish under --tag beta leaves beta as the only tag the package carries until the next ordinary publish puts latest back. Standalone npm dist-tag and npm unpublish are not part of the surface: tags are set by publishing, and removal goes through the management endpoints below.
Publishing is something your package manager does, not something the web app has a button for. Open the group, choose Packages, and the package appears in the list with its newest version, how many versions it holds and how much it stores — that is how you confirm a publish landed.
gitmoap package-registries publish-npm --group-id <id> --package <package>curl -X PUT https://api.gitmoap.example/packages/{group_id}/npm/{package} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Install npm packages from your registry
A consumer needs the same two .npmrc lines. A machine that only installs should use a service-account token minted with package:read alone, so a leaked CI secret cannot publish.
Resolution then works as usual: npm asks for the package document, picks a version, and follows the tarball URL in it. That document carries the tag map, every published version's manifest and each version's publish time. The tarball URLs Gitmoap hands back always point at Gitmoap, whatever was in the tarball's own metadata when it was published.
Open the group, choose Packages, and select the package. The Versions tab lists every published version with its size, its publish time and the person or service account that published it.
gitmoap package-registries get-npm-packument \ --group-id <id> \ --package <package>curl -X GET https://api.gitmoap.example/packages/{group_id}/npm/{package} \ -H "Authorization: Bearer $GITMOAP_TOKEN"To pull one specific tarball without npm — a checksum audit, a mirror script — request it directly and follow the redirect:
Open the package, find the version in the Versions list, and use the download action at the end of its row. Your browser follows the redirect and saves the tarball.
gitmoap package-registries get-npm-tarball \ --group-id <id> \ --package <package> \ --version <version> \ --filename <filename>curl -X GET https://api.gitmoap.example/packages/{group_id}/npm/{package}/-/{version}/{filename} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Note
A scoped name is one path segment, so its / is percent-encoded as %2F — @acme/ui is @acme%2Fui in a URL you build by hand. npm does this itself; a curl command written from memory usually does not, which is the most common cause of a surprising 404 here.
Search the npm registry
npm search --registry=<your registry URL> <text> works against a Gitmoap registry. Matching is by word and by substring, so serde_j finds serde_json — package names are identifiers, not prose, and a word-stemming search would miss that. Results page by offset, up to 100 at a time.
npm's wire format carries a score block, and the values in it are fixed placeholders rather than measurements — quality, popularity and maintenance are npmjs.com heuristics over download counts and public-repository signals a private registry has no source for. They say nothing about your packages, so read the names and versions and ignore the numbers beside them.
Searching is a package-manager call. To find something in the web app instead, open the group, choose Packages and switch to the npm format tab — the listing is the whole registry, newest change first.
gitmoap package-registries search-npm --group-id <id> --text <text>curl -X GET https://api.gitmoap.example/packages/{group_id}/npm/-/v1/search \ -H "Authorization: Bearer $GITMOAP_TOKEN"Publish a crate
Declare the registry in .cargo/config.toml, note the sparse+ prefix:
[registries.gitmoap]index = "sparse+https://api.gitmoap.com/packages/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/cargo/"Supply the token as CARGO_REGISTRIES_GITMOAP_TOKEN, or store it with cargo login --registry gitmoap. Then cargo publish --registry gitmoap.
The crate name is lowercased when it is stored, so My_Crate and my_crate are one crate — and publishing the same version under both spellings conflicts, because the second .crate file will not be byte-identical to the first.
Cargo publishes; the web app reports. Open the group, choose Packages, and switch to the Cargo format tab to see the new version and how much the crate now stores.
gitmoap package-registries publish-cargo --group-id <id>curl -X PUT https://api.gitmoap.example/packages/{group_id}/cargo/api/v1/crates/new \ -H "Authorization: Bearer $GITMOAP_TOKEN"Depend on a crate from your registry
With the registry declared as above, a dependency names it:
[dependencies]acme-core = { version = "1.2", registry = "gitmoap" }Cargo reads the registry's configuration document first to learn where to download crate files from, where to send publishes and yanks, and whether it needs to send credentials at all. For a private or internal group that document says authentication is required and Cargo sends your token with index and download requests as well as with publish; for a public group it says none is required, and anyone can depend on the crate with no token configured.
Open the group and choose Packages, then the Cargo tab. The panel shows the registry URL to paste into .cargo/config.toml and whether the registry requires authentication.
gitmoap package-registries get-cargo-config --group-id <id>curl -X GET https://api.gitmoap.example/packages/{group_id}/cargo/config.json \ -H "Authorization: Bearer $GITMOAP_TOKEN"Check what Cargo sees for a crate
The index is one line of JSON per published version, including the dependency list and the yanked flag. Reading it directly is the fastest way to answer "why did resolution pick that version" without a build.
Which URL serves a crate depends on the length of its name, following the sparse-registry layout: a name of three characters or more lives under two prefix segments (se/rd/serde), and a one- or two-character name lives under a single one (2/rs).
Open the group, choose Packages, select the crate, and open the Versions tab. Each row shows the version, whether it is yanked, its size and who published it. The checksum lives in the index a client reads, not in that view.
gitmoap package-registries get-cargo-index-long \ --group-id <id> \ --seg1 <seg1> \ --seg2 <seg2> \ --seg3 <seg3>curl -X GET https://api.gitmoap.example/packages/{group_id}/cargo/{seg1}/{seg2}/{seg3} \ -H "Authorization: Bearer $GITMOAP_TOKEN"For a crate whose name is one or two characters:
The web app shows short-named crates in the same package list — the split only affects the URL a client fetches, not what you see.
gitmoap package-registries get-cargo-index-short \ --group-id <id> \ --seg1 <seg1> \ --seg2 <seg2>curl -X GET https://api.gitmoap.example/packages/{group_id}/cargo/{seg1}/{seg2} \ -H "Authorization: Bearer $GITMOAP_TOKEN"To pull the crate file itself, request the download and follow the redirect:
Open the crate, find the version, and use the download action at the end of its row.
gitmoap package-registries download-cargo-crate \ --group-id <id> \ --crate-name <crate name> \ --version <id>curl -X GET https://api.gitmoap.example/packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/download \ -H "Authorization: Bearer $GITMOAP_TOKEN"Yank a crate version
cargo yank --registry gitmoap --version 1.2.3 acme-core marks a version so that no new resolution picks it, while every build that already pinned it keeps working. Nothing is deleted and no storage is freed. Yanking a version that is already yanked changes nothing and still succeeds.
Open the group, choose Packages, select the crate, and open the Versions tab. Use Yank at the end of the version's row; the row stays in the list with a yanked badge.
gitmoap package-registries yank-cargo \ --group-id <id> \ --crate-name <crate name> \ --version <version>curl -X DELETE https://api.gitmoap.example/packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/yank \ -H "Authorization: Bearer $GITMOAP_TOKEN"Restore a yanked crate version
cargo yank --undo --registry gitmoap --version 1.2.3 acme-core puts the version back into resolution. Because a yank never removed anything, this is a complete restoration.
On the crate's Versions tab, use Unyank on a yanked row. The badge disappears and the version is resolvable again.
gitmoap package-registries unyank-cargo \ --group-id <id> \ --crate-name <crate name> \ --version <version>curl -X PUT https://api.gitmoap.example/packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/unyank \ -H "Authorization: Bearer $GITMOAP_TOKEN"Search the Cargo registry
cargo search --registry gitmoap <query> searches the group's crates by word and by substring. Cargo pages by 1-based page number rather than by cursor, up to 100 results per page, because that is what the protocol specifies.
As with npm, searching is a Cargo call. Open the group, choose Packages and switch to the Cargo format tab to browse the registry in the web app.
gitmoap package-registries search-cargo --group-id <id> --q <q>curl -X GET https://api.gitmoap.example/packages/{group_id}/cargo/api/v1/crates \ -H "Authorization: Bearer $GITMOAP_TOKEN"Publish a Python distribution
Add the repository to ~/.pypirc. The token goes in the password; the username is not what authenticates you, and __token__ is the convention:
[distutils]index-servers = gitmoap [gitmoap]repository = https://api.gitmoap.com/packages/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/pypi/username = __token__password = gmp_your_token_hereThen twine upload --repository gitmoap dist/*. Twine sends one file per request, so an sdist and a wheel are two separate publishes and either can succeed while the other fails.
The project name is normalised the PEP 503 way, so My_Package.Core, my-package-core and MY.PACKAGE.CORE are all the same project.
Twine uploads; the web app reports. Open the group, choose Packages, switch to the PyPI tab and the project appears with the version you just uploaded and the bytes it now stores.
gitmoap package-registries upload-pypi --group-id <id>curl -X POST https://api.gitmoap.example/packages/{group_id}/pypi/ \ -H "Authorization: Bearer $GITMOAP_TOKEN"Install Python packages from your registry
Point pip at the project index and give it the credentials inline. In pip.conf or pip.ini:
[global]extra-index-url = https://__token__:gmp_your_token_here@api.gitmoap.com/packages/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/pypi/simple/The project page lists every distribution file for the project with its SHA-256 attached, which is what pip verifies each download against. It is served as HTML or as JSON depending on what your client asks for, so both a modern resolver and an old one work unchanged.
Open the group, choose Packages, switch to the PyPI tab and select the project. Each version shows its total size, when it was uploaded and who uploaded it; the per-file digests are in the index pip reads.
gitmoap package-registries get-pypi-project \ --group-id <id> \ --project <project>curl -X GET https://api.gitmoap.example/packages/{group_id}/pypi/simple/{project}/ \ -H "Authorization: Bearer $GITMOAP_TOKEN"Warning
extra-index-url means pip considers both indexes and takes the highest version it finds in either. If a public project on PyPI shares a name with one of yours, pip can silently prefer the public one. Give private projects names that nobody else publishes, or use index-url to make Gitmoap the only index a locked-down build is allowed to read.
List every Python project in a registry
The simple index is the group's PyPI project list — what a resolver walks and what a mirror script starts from. It comes back as HTML for an ordinary client and as JSON for one that asks for it, and it lists every project in the group's PyPI registry, alphabetically, in one response: the simple index has no pages, so nothing is left for a second request.
Open the group, choose Packages and switch to the PyPI tab. The list is the same set of projects the index serves.
gitmoap package-registries list-pypi-projects --group-id <id>curl -X GET https://api.gitmoap.example/packages/{group_id}/pypi/simple/ \ -H "Authorization: Bearer $GITMOAP_TOKEN"Download one distribution file
Request the file and follow the redirect. Each file's URL carries its project, version and filename, so it is stable and reproducible.
Open the project, find the version, and use its download action. Your browser follows the redirect and saves the file.
gitmoap package-registries download-pypi-file \ --group-id <id> \ --project <project> \ --version <id> \ --filename <id>curl -X GET https://api.gitmoap.example/packages/{group_id}/pypi/files/{project}/{version}/{filename} \ -H "Authorization: Bearer $GITMOAP_TOKEN"See what a group has published
The management listing spans all three formats and reports, per package, its latest version, how many versions it has, how many bytes it occupies and when it last changed. Packages come back most-recently-changed first. Pass a format to page through that one ecosystem with the cursor; omit it and you get a single overview page — up to 100 packages per format, with no cursor to continue from.
Latest version means the version published most recently, not the highest one. Publishing 1.0.1 as a patch after 2.0.0 is already out makes 1.0.1 the latest version the listing reports.
Open the group and choose Packages. The list shows every package across the three formats with its latest version, version count and total size, and the format tabs narrow it to one ecosystem.
gitmoap package-registries list-group-packages --group-id <id>curl -X GET https://api.gitmoap.example/groups/{group_id}/packages \ -H "Authorization: Bearer $GITMOAP_TOKEN"Inspect a package and its versions
The detail view is the one place that shows every version of a package with its size, whether it is yanked, when it was published and by whom — including publishes made by a service account from a pipeline.
Open the group, choose Packages and select the package. The header carries the total size and version count; the Versions tab carries the per-version detail.
gitmoap package-registries get-group-package \ --group-id <id> \ --format <format> \ --package-name <package name>curl -X GET https://api.gitmoap.example/groups/{group_id}/packages/{format}/{package_name} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Delete one version
Warning
Deleting a version destroys its files and cannot be undone. Any build that pins that exact version stops resolving immediately — a lockfile referring to it fails rather than falling back. For a Cargo release you merely want to withdraw, yank it instead: a yanked version keeps every existing build working. npm and PyPI have no yank, so withdrawing there means either deleting or leaving the version in place. Deleting is the right move only when the bytes themselves must go, for example a package published with a secret baked into it.
Address the package by the name you published: the delete normalises it the way its format does, so My_Package.Core and my-package-core reach the same PyPI project and My_Crate reaches my_crate. npm is the exception, because npm names are stored exactly as they were published — there the case has to match. The bytes are released back to the namespace's storage budget as part of the delete.
Deleting the last version of a package leaves the package itself in the listing with no versions left in it. Delete the package to take it out of the list.
Open the group, choose Packages, select the package and open the Versions tab. Use the delete action at the end of the version's row; the confirmation names the version and the bytes it destroys.
gitmoap package-registries delete-group-package-version \ --group-id <id> \ --format <format> \ --package-name <package name> \ --version <id>curl -X DELETE https://api.gitmoap.example/groups/{group_id}/packages/{format}/{package_name}/versions/{version} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Delete a package and every version of it
Warning
This destroys every version of the package and every file in every version, in one action. Nothing is recoverable and no dependent build survives it. If you are retiring a package rather than erasing it, yank its versions where the format allows it, or simply stop publishing new ones — an unused package costs only its stored bytes.
Open the group, choose Packages and select the package. Use Delete package in the header. The confirmation names the package and how many versions and bytes go with it.
gitmoap package-registries delete-group-package \ --group-id <id> \ --format <format> \ --package-name <package name>curl -X DELETE https://api.gitmoap.example/groups/{group_id}/packages/{format}/{package_name} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Reference
The first-party management surface — the endpoints behind the Packages view, and the ones to script against:
4 endpoints
GET/groups/{group_id}/packagespackage:read
List a group's packages
Token scope
package:read
Send
Path
- group_iduuidOwning group id
Query
- formatstringoptionalFilter by format (npm/cargo/pypi)
- limitint32optionalPage size (1-100, default 20)
- cursorstringoptionalOpaque pagination cursor
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404
Receive
items[]object6 fields
- formatcargonpmpypiThe package ecosystem a registry speaks. A group has at most one registry per format.
- latest_versionobjectoptionalThe newest version, when there is one.
- namestringThe package's name, as it was published.
- total_bytesint64The size of every file of every version, in bytes.
- updated_atstring
- version_countint64
- next_tokenobjectoptional
GET/groups/{group_id}/packages/{format}/{package_name}package:read
A package's detail with its versions
Token scope
package:read
Send
Path
- group_iduuidOwning group id
- formatcargonpmpypiRegistry format (npm/cargo/pypi)
- package_namestringPackage name
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404
Receive
No body.
DELETE/groups/{group_id}/packages/{format}/{package_name}package:delete
Delete an entire package
delete_group_packageDELETE /groups/{group_id}/packages/{format}/{package_name}Open in the API referenceToken scope
package:delete
Send
Path
- group_iduuidOwning group id
- formatcargonpmpypiRegistry format
- package_namestringPackage name
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403404409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
DELETE/groups/{group_id}/packages/{format}/{package_name}/versions/{version}package:delete
Delete a single package version
delete_group_package_versionDELETE /groups/{group_id}/packages/{format}/{package_name}/versions/{version}Open in the API referenceToken scope
package:delete
Send
Path
- group_iduuidOwning group id
- formatcargonpmpypiRegistry format
- package_namestringPackage name
- versionstringVersion
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403404409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
The npm protocol surface. These are the shapes npm itself calls; you rarely invoke them by hand:
4 endpoints
GET/packages/{group_id}/npm/{package}package:read
Serve an npm packument
Token scope
package:read
Send
Path
- group_iduuidOwning group id
- packagestringPackage name (scoped names percent-encoded)
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404501
Receive
No body.
PUT/packages/{group_id}/npm/{package}package:write
Publish a package version (`npm publish`)
Token scope
package:write
Counts against
package_bytes
Send
Path
- group_iduuidOwning group id
- packagestringPackage name (percent-encoded)
Refuses
400The document is malformed, or names a different package than the address401403404Not found — or found and private, which is masked as absent409429501
Receive
No body.
GET/packages/{group_id}/npm/{package}/-/{version}/{filename}package:read
Download a package tarball
get_npm_tarballGET /packages/{group_id}/npm/{package}/-/{version}/{filename}Open in the API referenceToken scope
package:read
Send
Path
- group_iduuidOwning group id
- packagestringPackage name (percent-encoded)
- versionstringVersion
- filenamestringTarball filename
Refuses
302Redirect to a short-lived download link400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404501
Receive
No body.
GET/packages/{group_id}/npm/-/v1/searchpackage:read
Search a group's npm registry
Token scope
package:read
Send
Path
- group_iduuidOwning group id
Query
- textstringSearch text (
npm search <text>) - sizeint32optionalPage size (npm's default 20, max 100)
- fromint32optionalOffset — npm pages by offset, not cursor
Refuses
400401403Forbidden — the caller's access level or token scope is insufficient404
Receive
No body.
The Cargo protocol surface, including the sparse index in both of its path layouts:
8 endpoints
GET/packages/{group_id}/cargo/config.jsonpackage:read
The Cargo sparse-registry `config.json`
Token scope
package:read
Send
Path
- group_iduuidOwning group id
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404501
Receive
No body.
GET/packages/{group_id}/cargo/{seg1}/{seg2}package:read
Sparse-index file for a crate whose name is one or two characters long
Token scope
package:read
Send
Path
- group_iduuidOwning group id
- seg1stringIndex prefix
- seg2stringCrate name
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404
Receive
No body.
GET/packages/{group_id}/cargo/{seg1}/{seg2}/{seg3}package:read
Sparse-index file for a crate whose name is three or more characters long
Token scope
package:read
Send
Path
- group_iduuidOwning group id
- seg1stringIndex prefix part 1
- seg2stringIndex prefix part 2
- seg3stringCrate name
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404
Receive
No body.
PUT/packages/{group_id}/cargo/api/v1/crates/newpackage:write
Publish a crate (`cargo publish`)
Token scope
package:write
Counts against
package_bytes
Send
Path
- group_iduuidOwning group id
Refuses
400401403404Not found — or found and private, which is masked as absent409429501
Receive
No body.
GET/packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/downloadpackage:read
Download a `.crate` file
download_cargo_crateGET /packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/downloadOpen in the API referenceToken scope
package:read
Send
Path
- group_iduuidOwning group id
- crate_namestringCrate name
- versionstringVersion
Refuses
302Redirect to a short-lived download link400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404501
Receive
No body.
DELETE/packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/yankpackage:write
Yank a crate version (`cargo yank`)
yank_cargoDELETE /packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/yankOpen in the API referenceToken scope
package:write
Send
Path
- group_iduuidOwning group id
- crate_namestringCrate name
- versionstringVersion
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403404409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
PUT/packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/unyankpackage:write
Restore a yanked crate version (`cargo yank --undo`)
unyank_cargoPUT /packages/{group_id}/cargo/api/v1/crates/{crate_name}/{version}/unyankOpen in the API referenceToken scope
package:write
Send
Path
- group_iduuidOwning group id
- crate_namestringCrate name
- versionstringVersion
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403404409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
GET/packages/{group_id}/cargo/api/v1/cratespackage:read
Search a group's Cargo registry
Token scope
package:read
Send
Path
- group_iduuidOwning group id
Query
- qstringSearch text (
cargo search <q>) - per_pageint32optionalPage size (Cargo's default 10, max 100)
- pageint32optional1-based page number — Cargo pages by number, not cursor
Refuses
400401403Forbidden — the caller's access level or token scope is insufficient404
Receive
No body.
The PyPI protocol surface — a PEP 503 and PEP 691 index, plus the legacy upload endpoint twine posts to:
4 endpoints
GET/packages/{group_id}/pypi/simple/package:read
List the projects in a group's PyPI registry (simple index)
Token scope
package:read
Send
Path
- group_iduuidOwning group id
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404Not found — or found and private, which is masked as absent501
Receive
No body.
GET/packages/{group_id}/pypi/simple/{project}/package:read
List a PyPI project's files (simple index)
Token scope
package:read
Send
Path
- group_iduuidOwning group id
- projectstringProject name
Refuses
400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404501
Receive
No body.
POST/packages/{group_id}/pypi/package:write
Upload a distribution file (`twine upload`)
Token scope
package:write
Counts against
package_bytes
Send
Path
- group_iduuidOwning group id
Refuses
400401403404Not found — or found and private, which is masked as absent409429501
Receive
No body.
GET/packages/{group_id}/pypi/files/{project}/{version}/{filename}package:read
Download a distribution file
download_pypi_fileGET /packages/{group_id}/pypi/files/{project}/{version}/{filename}Open in the API referenceToken scope
package:read
Send
Path
- group_iduuidOwning group id
- projectstringProject name
- versionstringVersion
- filenamestringDistribution filename
Refuses
302Redirect to a short-lived download link400Bad request — a path parameter is not a well-formed value of its type401403Forbidden — the caller's access level or token scope is insufficient404501
Receive
No body.
Limits
Two caps apply to publishing, and both are enforced before any bytes are kept.
| Limit | Free-tier value | What it applies to |
|---|---|---|
| Size of a single published file | 500 MiB | each tarball, .crate, sdist or wheel |
| Stored bytes in the namespace | 5 GiB | every stored byte in the root group — packages, repositories and release assets together |
A file over the size cap is refused with 400 BAD_REQUEST and a message naming the byte limit. A publish that would push the group's namespace past its storage budget is refused with 429 TOO_MANY_REQUESTS. Storage is reserved before the bytes are accepted, so a refusal leaves nothing behind — no orphaned file, no half-created version, and no reservation to reclaim.
There is a second ceiling beside the size cap, and unlike the size cap it does not move with your plan: the registry paths accept a request body up to 512 MiB, and a body over that is refused 413 before the handler sees it. It bites npm first, because an npm publish carries the tarball base64-encoded in a JSON envelope, which inflates it by about a third — a tarball much beyond 380 MiB exceeds the request ceiling and is refused as too large before the per-file cap is ever consulted. Cargo and twine send the artifact as raw bytes, so on the Free tier they meet the 500 MiB cap first; on a plan whose per-file cap is larger than 512 MiB, the request ceiling is the real limit for every format.
There is no cap on the number of packages or versions a registry holds. Stored bytes are the only ceiling, which is why deleting matters and yanking does not: a yank leaves the bytes in place and frees nothing, while deleting a version or a package releases its bytes back to the budget immediately.
Downloads are charged against outbound-byte budgets rather than the storage budget: one for the caller, over the current hour and the current day, and one for the namespace over the current day. When a budget is exhausted, a download answers 429 TOO_MANY_REQUESTS naming the budget that tripped, and because the charge is taken before the redirect is issued, a refusal means nothing was sent at all.
Page sizes are bounded on every paged listing: the packages listing takes 1–100 (default 20), npm search up to 100 (default 20, paged by offset), and Cargo search up to 100 (default 10, paged by 1-based page number). A page size outside its range is clamped to the range rather than refused. The PyPI simple index is not paged: it lists every project at once.
To raise a cap, an Owner of the group can request a limit increase; the alternative is to delete versions nobody depends on. Both the storage budget and the caps in force are visible on the group's usage view.
Troubleshooting
400 BAD_REQUESTnaming an invalid format
- Cause
- The format segment of the URL, or the
formatfilter on the packages listing, is something other thannpm,cargoorpypi - What to do
- Use one of the three exactly, in lowercase
400 BAD_REQUESTmentioning an invalid package name, version or filename
- Cause
- The value falls outside the accepted charset — a space, a
.., a leading-, or a character outsideA–Z a–z 0–9 . _ - @ / - What to do
- Rename the package or retag the version. A filename may not contain a path separator or
..
400 BAD_REQUEST— sha256 digest mismatch, integrity (sha512) mismatch or shasum (sha1) mismatch
- Cause
- The bytes received do not hash to what the client declared: a truncated upload, a proxy that rewrote or recompressed the body, or an artifact rebuilt between packing and publishing
- What to do
- Republish from a clean build in one step. If a corporate proxy sits in front of the request, exclude the registry host from body rewriting
400 BAD_REQUEST— package file exceeds the … byte limit
- Cause
- A single file is over the per-file cap
- What to do
- Split the distribution, strip bundled assets that belong in object storage, or ask an Owner to request a limit increase
413on publish, with no message about the size cap
- Cause
- The whole request body exceeded 512 MiB — usually an npm tarball whose base64 envelope inflated past the ceiling
- What to do
- Reduce the tarball below roughly 380 MiB, or publish the large payload as a release asset and depend on it separately
400 BAD_REQUEST— the search text must not be empty
- Cause
- A search was issued with no query, or with only whitespace
- What to do
- Give the search a term. Use the group's Packages list to browse without one
401 UNAUTHORIZED
- Cause
- No token reached the request — including an anonymous fetch from a group that is not Public — or the token is expired or revoked. The most common cause is an
.npmrctoken line whose host or path does not exactly match the registry URL - What to do
- Check the token line matches the registry URL character for character, including the trailing
/, then mint a fresh token if it is old
403 FORBIDDENon publish or yank
- Cause
- You can read the group but your level on it is below Developer, which is what publishing, yanking and restoring need
- What to do
- Ask an Owner or Maintainer of the group to raise your access level
403 FORBIDDENon delete, where publishing works
- Cause
- Deleting needs Maintainer, one level above publishing
- What to do
- Ask an Owner to raise your access level, or ask a Maintainer to delete it for you
403 INSUFFICIENT_SCOPE
- Cause
- The token is valid but was minted without the scope this call needs
- What to do
- Mint a new token carrying
package:writeorpackage:delete. Scopes are fixed at mint time and cannot be added to an existing token
403 FORBIDDEN— the token is not authorized for this resource
- Cause
- The token was bound to one group or repository when it was minted, and this registry belongs to another
- What to do
- Use a token bound to this group or to an ancestor of it, or an unbound one. A binding is fixed at mint time, like a scope
404 NOT_FOUNDfor a group you can see in the web app
- Cause
- The group is private and the token's owner — you, or the service account — is not a member of it
- What to do
- A private group answers
404rather than403so its existence is not leaked. Add the token's owner to the group as a Reporter or above, or use a token whose owner is already a member
404 NOT_FOUNDfor a package you published minutes ago
- Cause
- The npm name differs in case from the one that was published, or the group id or format segment in the URL is wrong
- What to do
- Copy the name out of the group's Packages list. Cargo and PyPI names are normalised for you on both publish and lookup, so only npm is case-sensitive:
@Acme/UIand@acme/uiare two different packages
404 NOT_FOUNDon a scoped npm package fetched by hand
- Cause
- The
/in@acme/uisplit into two path segments - What to do
- Percent-encode it as
@acme%2Fui. npm does this itself; a hand-written request does not
404 NOT_FOUNDyanking or deleting a version
- Cause
- No version with that exact string exists — a
vprefix, a normalised PEP 440 form, or a version somebody already deleted - What to do
- Read the package detail, copy the version string it lists, and reissue. Yanking an already-yanked version succeeds, so a
404here is always the version, never its state
409 CONFLICTon publish
- Cause
- That name, version and filename already exist with different bytes. Republishing the identical bytes is a success, so this means the artifact genuinely changed
- What to do
- Bump the version. A published version is immutable; nothing about the stored one was altered by the attempt
429 TOO_MANY_REQUESTSon publish
- Cause
- The group's namespace storage budget is full
- What to do
- Delete versions nobody depends on — yanking frees nothing — or have an Owner request a limit increase. Nothing was stored, so no cleanup is needed
429 TOO_MANY_REQUESTSon a download
- Cause
- An outbound-byte budget is exhausted — the caller's for this hour or this day, or the namespace's for this day. The response names which one tripped
- What to do
- Wait for that window to roll over, or spread the pull across fewer concurrent jobs. Nothing was transferred, so a retry costs nothing extra
501 NOT_IMPLEMENTED— Package registry is not configured on this deployment
- Cause
- Package storage is unavailable on Gitmoap's side, so there is nowhere to keep the bytes
- What to do
- Contact support@gitmoap.com. No client change makes this call work
501 NOT_IMPLEMENTED— Package registry base URL is not configured
- Cause
- Gitmoap cannot build the absolute download URLs a package document has to carry
- What to do
- Contact support@gitmoap.com
npm install resolves your package from npmjs.com
- Cause
- The scope is not mapped to the registry, so npm used its default
- What to do
- Add the
@scope:registry=line to.npmrc. A scope mapping is per scope — a package published without one has no way to route
cargo reports that the registry index could not be found
- Cause
- The
indexvalue is missing thesparse+prefix, so Cargo looked for a git index - What to do
- Prefix the URL with
sparse+. The whole value issparse+https://…/cargo/
pip install picks a public project with the same name
- Cause
extra-index-urlmakes pip consider both indexes and take the highest version, and a public project can outrank yours- What to do
- Use names nobody else publishes, or make Gitmoap the only index with
index-urlfor builds that must not reach the internet
A hand-written HTTP client gets an empty body and a 302
- Cause
- The download answered with a redirect to the bytes and the client did not follow it
- What to do
- Follow redirects —
curl -L. Do not store the redirect target: it expires
A build that pinned a version fails after somebody deleted it
- Cause
- Deleting removes the bytes, so a pinned resolution has nothing left to fetch
- What to do
- Republish the artifact under a new version and update the lockfile. Next time, yank the version instead of deleting it — a yanked Cargo version keeps every existing build resolving
Storage did not drop after yanking a bad release
- Cause
- A yank flips a flag; it never removes a file
- What to do
- Delete the version if you need the bytes back. Yank and delete are separate actions with separate access levels
FAQ
Can a CI job publish without a person's token?
Yes, and it should. Create a service account on the group, give it Developer, and mint it a gms_ token with package:write. Publishes then show the service account as the publisher, and revoking its token does not disturb anybody's personal access.
Do packages count against the same storage as repositories?
Yes. Stored bytes are budgeted per namespace — the root group — and packages, git repositories and release assets all draw on the same 5 GiB on the Free tier.
What happens to a group's packages when the group is deleted?
They go with it. Deleting a group destroys its registries, every package in them and every stored byte, and releases the storage.
Does renaming or moving a group break my builds?
No. Registry URLs carry the group's id, not its path, so a rename or a move leaves every configured client working.
Can I search PyPI projects the way I search npm and Cargo?
No. The PyPI protocol defines an index, not a query, so there is no ranked search for it the way there is for npm and Cargo. Read the group's Packages list with the format filter set to PyPI, or the simple index itself, and match names on your side.
Related
- Access tokensminting personal and service-account tokens, and choosing the scopes a package client needs
- Groupscreating the group a registry belongs to, its visibility, who is a member of it, and where the storage budget is reported
- Service accountsthe non-human identity a pipeline publishes as
- Pipelinesrunning the build that produces the artifact you publish
- Billing and planswhat each plan raises, and how to ask for a bigger storage budget
- Releasesattaching large binaries to a tagged release when they are not a package anyone installs