Package Registry
Canopy Host includes an npm-protocol registry. It is a real registry, not
a proxy: npm, bun, pnpm and Changesets all talk to it unmodified,
because it speaks the npm protocol rather than a Canopy-specific one.
Gated behind the host.registry platform feature.
Getting set up
A package lives under a scope (@acme/widgets). A scope is registered
to a team as a registry namespace, and a team can hold several.
canopy registry namespace create @acme --team <team_id>Registering a scope is enough to publish into it. Verifying it (a DNS TXT
record at _canopy-registry.<your-domain>) is optional and separate: it
attaches a provenance attestation to every publish and is what the
“verified” badge asserts.
canopy registry namespace verify <namespace_id> --team <team_id> --domain acme.comcanopy registry namespace status <namespace_id> --team <team_id>Then point your project’s npm client at Canopy:
canopy registry login --scope @acmeThat writes a managed block into ./.npmrc:
# >>> canopy registry (managed by `canopy registry login`) >>>@acme:registry=https://api.canopy.pm/registry/npm///api.canopy.pm/registry/npm/:_authToken=chy_...# <<< canopy registry <<<Re-running login rewrites that block rather than appending a second
copy, so rotating your API key works by just running it again. Anything
else in your .npmrc is left untouched.
Check connectivity the ordinary way:
npm ping --registry https://api.canopy.pm/registry/npm/npm whoami --registry https://api.canopy.pm/registry/npm/Publishing a new version
There is no Canopy-specific publish tool, and you don’t need one. Once
.npmrc is configured, the standard workflow works:
npm version patch # or minor / major - rewrites package.json, tags gitnpm publish # or: bun publishcanopy registry publish exists as a shortcut for the last step. It
really does shell out to npm publish - it just refuses to run when no
scoped registry is configured, so a private package can’t leak to
registry.npmjs.org by accident:
canopy registry publish [--tag <tag>] [--dry-run]Versions are immutable
Publishing a version that already exists is a 409, always. There is no unpublish and no overwrite - a published version stays exactly as published, forever. If you shipped a mistake, publish a new version.
This is stricter than npmjs.com (which allows an unpublish within 72 hours) and deliberately so: anything installed from this registry can be cached and depended on, and a mutable version is a supply-chain hazard.
Prereleases and dist-tags
A plain npm publish moves the latest tag. To ship a prerelease that
doesn’t:
npm publish --tag nextTo promote an already-published version to latest later - or to manage
any other tag:
npm dist-tag add @acme/widgets@2.0.0 latestnpm dist-tag ls @acme/widgetsnpm dist-tag rm @acme/widgets nextThe same operations are available from the Canopy CLI, which is handy on
a machine that is logged into canopy but has no .npmrc:
canopy registry dist-tag ls <@scope/name>canopy registry dist-tag add <@scope/name@version> <tag>canopy registry dist-tag rm <@scope/name> <tag>latest can never be removed - every package must always have one, or a
bare npm install would have nothing to resolve.
The very first version ever published for a package always becomes
latest, even under --tag next, since there is nothing else for
latest to point at yet.
Seeing what’s published
canopy registry info <@scope/name> # every dist-tag and versioncanopy registry stats <@scope/name> # pull counts by versioncanopy registry daily-pulls <@scope/name> <version> [--since-days <n>]npm view @acme/widgets works too.
Public and private scopes
By default every scope is private: installing from it requires a Canopy API key, the same as publishing.
A scope can be made public, which means anyone can install packages under it with no Canopy account and no credential at all - the way you’d ship an SDK or CLI to your own customers.
canopy registry namespace public <namespace_id> --team <team_id>canopy registry namespace public <namespace_id> --team <team_id> --privateOr from the dashboard: Teams → Registry, then the Make public / Make private button next to the scope.
Visibility is per scope, not per package - the same boundary
npmjs.com’s --access public, GitLab’s project visibility and GitHub
Packages’ repository visibility use. Making @acme public makes
everything under it installable, which avoids the failure mode where one
forgotten package in an otherwise-public scope silently 401s a customer.
What “public” does and does not change:
| private scope | public scope | |
|---|---|---|
npm install (packument, tarball, dist-tag reads) | API key required | no credential needed |
npm publish, npm dist-tag add/rm | API key + registry:publish | API key + registry:publish |
| Pull counts / analytics | API key required | API key required |
Changing visibility is gated on registry:admin (the same permission as
registering and verifying a scope), not registry:publish - exposing a
scope to the internet is an org-level decision, not something a
publishing job should be able to flip.
Installing from a public scope
Your users need one line of config and nothing else:
npm config set @acme:registry https://api.canopy.pm/registry/npm/npm install @acme/widgetsor, in their project’s .npmrc:
@acme:registry=https://api.canopy.pm/registry/npm/Docker images
The same team namespaces also own Docker images at registry.canopy.pm. The
first path component of an image name is the namespace (without the @), so
@acme owns registry.canopy.pm/acme/....
canopy images login --team <team_id>docker build -t registry.canopy.pm/acme/build-tools:1 .docker push registry.canopy.pm/acme/build-tools:1canopy images login creates a dedicated API key with the images:write
scope (or images:read with --read-only, for a machine that only pulls) and
runs docker login registry.canopy.pm with your email and that key. The key
only works against the registry: it cannot reach the rest of the API, and a
general-purpose key is refused by the registry. --team pins it to one team.
You can also do it by hand: create a key with operation_scope: images:write
and docker login registry.canopy.pm -u <your email> with the key as the
password.
Pushing needs the registry:publish role on the namespace’s team; pulling
needs team read access. Nothing outside your namespaces is reachable.
canopy images list --team <team_id>The dashboard shows the same under Images (tags with their size, layer count, platform and build time, read from the registry itself).
Using an image in a pipeline
Each step() in canopy.pipeline.ts takes its own image, so different steps
can run in different images (an SDK-drift step in one, the API tests in another).
Pin it by digest:
step('test', { image: 'registry.canopy.pm/acme/build-tools@sha256:<digest>', run: 'bun test', inputs: [src],});canopy images pin acme/build-tools:1 --team <team_id> resolves a tag you
pushed to that digest-pinned reference and prints the line to paste. The
dashboard’s Images page has the same as copy buttons (digest, image ref, or the
ready-made image: line).
A step’s identity stays content-addressed, so the pipeline itself only ever runs a digest. To write a tag instead, lock it:
step('test', { image: 'registry.canopy.pm/acme/build-tools:1', run: 'bun test', inputs: [src] });canopy images lock canopy.pipeline.ts --team <team_id> # writes canopy.images.lock next to it - commit itThe lock records the digest the tag points at, and Canopy swaps the tag for that
digest before validating, so the same commit always runs the same image. The
lock only moves when you ask (--update); --check fails in CI if the lock is
missing an entry or a tag has moved on. A tag with no lock entry is an error
that names the step. Only tags on registry.canopy.pm are locked this way;
canopy pipelines check applies the lock offline too. The team must own the namespace. The first time a pipeline
needs the image on a build Grove, Canopy copies it there, so the digest stays
identical everywhere. Bake your dependencies into the image once and the steps
stop reinstalling them on every run.
HTTP surface
The routes are the standard npm ones, under /registry/npm/. Package
specs are accepted both percent-encoded as a single segment
(@acme%2fwidgets, what real npm/bun clients send) and as two path
segments.
| Method | Path |
|---|---|
GET | /registry/npm/-/ping |
GET | /registry/npm/-/whoami |
GET | PUT | /registry/npm/{spec} |
GET | /registry/npm/{spec}/-/{filename} |
GET | /registry/npm/-/package/{spec}/dist-tags |
PUT | DELETE | /registry/npm/-/package/{spec}/dist-tags/{tag} |
GET | /registry/npm/{scope}/{name}/stats |
GET | /registry/npm/{scope}/{name}/{version}/pulls-daily |
GET | POST | /teams/{team_id}/registry-namespaces |
GET | PATCH | /teams/{team_id}/registry-namespaces/{namespace_id} |
POST | /teams/{team_id}/registry-namespaces/{namespace_id}/verify |
Full schemas: API Reference.