Skip to content

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.com
canopy registry namespace status <namespace_id> --team <team_id>

Then point your project’s npm client at Canopy:

canopy registry login --scope @acme

That 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 git
npm publish # or: bun publish

canopy 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 next

To promote an already-published version to latest later - or to manage any other tag:

npm dist-tag add @acme/widgets@2.0.0 latest
npm dist-tag ls @acme/widgets
npm dist-tag rm @acme/widgets next

The 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 version
canopy registry stats <@scope/name> # pull counts by version
canopy 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> --private

Or 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 scopepublic scope
npm install (packument, tarball, dist-tag reads)API key requiredno credential needed
npm publish, npm dist-tag add/rmAPI key + registry:publishAPI key + registry:publish
Pull counts / analyticsAPI key requiredAPI 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/widgets

or, 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:1

canopy 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 it

The 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.

MethodPath
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.