Trusted publishing
Every run of a CI job carries a proof of identity: the OIDC id_token its platform mints for it. Haven exchanges that proof for a publish token that lives 30 minutes, so the pipeline keeps no stored secret. This is Haven’s equivalent of crates.io trusted publishing, and the recommended way to publish from CI. Stored tokens cover the cases the exchange cannot.
How trust is granted
Section titled “How trust is granted”The exchange grants nothing by itself. Haven verifies the id_token
and derives an identity string from it, issuer#subject. For a
GitHub Actions job publishing from acme/tools:
https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/mainThe @123456 and @7890123 are GitHub’s numeric account and
repository IDs, embedded so the identity survives renames. GitHub
uses this immutable format by default for repositories created after
2026-07-15; older repositories emit the plain
repo:acme/tools:ref:refs/heads/main unless opted in. Haven matches
the string exactly as sent either way, so don’t type it by hand: run
the workflow once before any grant exists, and the exchange fails
with a 403 that names the exact identity to add.
That exact string must be listed as an owner before the exchange succeeds, exactly like a token subject, at either of two tiers: as an owner of the registry (trust over every package in it), or as an owner of one or more of its packages (see Per-crate trust below). For registry-wide trust, add it in the registry’s Settings tab (the “Trusted publishing (CI)” card), or through the API:
POST /api/v1/registries/acme/owners{ "subject": "https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main", "capabilities": ["publish"] }The grant carries a capability set: what the exchanged token may
do. publish alone is the safe default and what the Settings card
pre-checks: a compromised workflow can then at worst ship a bad
version, which stays visible and yankable, and can neither yank, nor
widen access, nor weaken policy. The other capabilities are yank,
annotate (vouches, holds, facts, docs uploads, attestations), and
manage (settings, owners, grants, promotions); see
Tokens for the full definitions. Omitting
capabilities grants all four. Re-adding an identity updates its
set, so grants created before capabilities existed (they behave as
all four) can be narrowed the same way.
See Registries for how owners gate publishing in general.
Per-crate trust
Section titled “Per-crate trust”When one repository publishes one crate, registry-wide trust is more than the pipeline needs: a compromised workflow could publish or overwrite every crate in a shared registry. Instead, add the CI identity as a package owner of just that crate, from the crate’s Settings tab (the same “Trusted publishing (CI)” form, scoped to the one crate) or through the API:
POST /api/v1/registries/acme/packages/some-crate/owners{ "subject": "https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main", "capabilities": ["publish"] }The exchange then accepts the identity because it owns a package of the named registry, and the minted token is bounded by that grant:
- It publishes and yanks only the packages the identity owns, within
each grant’s capability set. When the identity is trusted on
several packages, the token’s scope is the union of its grants, but
each package is still checked against its own grant, so one crate’s
yanknever leaks onto another. - New names stay registry-owner-gated: a per-crate CI identity can never squat a fresh name (the crates.io rule). Grants attach to existing packages only, so publish the first version yourself before trusting CI on the name.
- Removing or narrowing a grant cuts the access immediately, not at the token’s expiry: every grant — registry-wide ones included — is checked on every request.
- The tiers combine: an identity holding a registry-wide grant and package grants exchanges for the union of all of them, and each action is still checked against the grant that covers it — a capability granted on one package never unlocks a registry-wide lever.
- One caveat to
annotate: facts, advisories, and attestations are registry-wide surfaces with no per-package ownership, so a grant that includesannotatecan record facts, raise advisories, and attest anywhere in the registry.publishalone, the default, has no such reach.
Works the same on both formats: crates and files packages alike.
GitHub Actions subjects
Section titled “GitHub Actions subjects”The subject half is GitHub’s sub claim, and its shape depends on
what triggered the workflow (OWNER/REPO carries the @id suffixes
on repositories using the immutable format):
- Branch:
repo:OWNER/REPO:ref:refs/heads/main - Tag:
repo:OWNER/REPO:ref:refs/tags/v1.0.0 - Environment:
repo:OWNER/REPO:environment:release
Pin the subject to your release branch or to a protected
environment. The claim carries one concrete ref, so a tag subject
names one exact tag; for tagged releases, a protected release
environment is the practical pin.
The exchange call
Section titled “The exchange call”The CI job sends its id_token as the bearer credential and names the registry it wants to publish to:
curl -sSf https://haven.example.com/api/v1/tokens/exchange \ -H "Authorization: Bearer $ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{"registry": "acme"}'On success the response is 201, and this is the only time the
token is shown:
{ "ok": true, "token": "haven_...", "subject": "https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main", "expires_at": 1789000000}When the exchange fails:
401: noAuthorizationheader.403: the id_token failed verification, or its identity is neither an owner of the registry nor an owner of any of its packages (the response names the identity to add).404: the registry does not exist.501: trusted publishing is not enabled on this instance; ask the operator.
A complete workflow
Section titled “A complete workflow”The id_token must be requested with audience haven-registry;
Haven’s verifier accepts no other value. A minimal GitHub Actions
release job:
name: releaseon: push: branches: [main]
permissions: id-token: write contents: read
jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Publish to Haven run: | ID_TOKEN=$(curl -sSf \ -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=haven-registry" \ | jq -r .value) TOKEN=$(curl -sSf https://haven.example.com/api/v1/tokens/exchange \ -H "Authorization: Bearer $ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{"registry": "acme"}' \ | jq -r .token) export CARGO_REGISTRIES_ACME_TOKEN="$TOKEN" cargo publish --registry acmeThe registry itself is configured in .cargo/config.toml as usual;
see Publishing crates and
Getting started.
Static-files registries
Section titled “Static-files registries”The exchange works the same on a static-files registry: trust is ownership, and ownership is format-blind. Add the CI identity as an owner of the files registry, request the id_token exactly as above, and use the exchanged token on the files endpoints. The publish step of the job becomes:
TOKEN=$(curl -sSf https://haven.example.com/api/v1/tokens/exchange \ -H "Authorization: Bearer $ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{"registry": "assets"}' \ | jq -r .token)# Upload the version's files, then publish it.curl -sSf -X PUT -H "Authorization: Bearer $TOKEN" \ --data-binary @dist/some-tool.tar.gz \ https://haven.example.com/r/assets/files/some-tool/1.0.0/some-tool.tar.gzcurl -sSf -X POST -H "Authorization: Bearer $TOKEN" \ https://haven.example.com/api/v1/registries/assets/files/some-tool/1.0.0/publishToken properties
Section titled “Token properties”The exchanged token is a normal registry token with a short life:
- Expires 30 minutes after the exchange.
- A capability token, never admin: it carries the union of the identity’s trust grants on that registry, registry-wide and per-package alike, with each action still checked against the grant that covers it. It is confined to the one registry the exchange named — it reads that registry and touches nothing else: no sibling registries, no organization API.
- Revocable early from the organization’s Tokens page like any other token.
- The mint is recorded in the audit log under the CI identity, so every release names the workflow that made it.
Compared to crates.io
Section titled “Compared to crates.io”crates.io scopes trust per crate, to a repository and workflow file. Haven offers both tiers: per-crate trust is the same blast-radius boundary crates.io draws and the right default for one-repo-one-crate pipelines, while a registry-wide grant fits a release train that ships many crates at once. Either way, how much protection is left rests on how specific the subject string is: choose the narrowest subject your release flow allows, such as a release branch or a protected environment.